docs: fill missing XML docs and strip tracking IDs from src comments
Clears CommentChecker findings in src/ (3,280 -> 209, the remainder being false positives like PLC-5/IEEE-754/M0.0) by adding missing summaries/params/returns, normalising inheritdoc usage, and removing Gitea/review tracking IDs while keeping the rationale they annotated. Comment-only: comment-stripped sources are identical to the parent commit.
This commit is contained in:
@@ -54,7 +54,7 @@ public sealed class ProbeCommand : S7CommandBase
|
||||
|
||||
// Derive the headline verdict from BOTH the driver state AND the probe-read
|
||||
// StatusCode so the operator never sees `Health: Healthy` over a Bad snapshot
|
||||
// line (Driver.S7.Cli-012; mirrors Modbus.Cli's ComputeVerdict).
|
||||
// line (mirrors Modbus.Cli's ComputeVerdict).
|
||||
var verdict = ComputeVerdict(health.State, snapshot[0].StatusCode);
|
||||
|
||||
await console.Output.WriteLineAsync($"Host: {Host}:{Port}");
|
||||
|
||||
@@ -25,7 +25,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.LibplctagShared;
|
||||
/// release cannot run; the tag is then rooted for the life of the process instead, which keeps its delegate
|
||||
/// alive. That is no crash but an UNBOUNDED leak — one native tag (holding its libplctag session open) per failed
|
||||
/// create, and the data path re-creates a NotFound tag on every read — so <c>LibplctagOrphanReleaseTests</c> / <c>LibplctagLegacyOrphanReleaseTests</c> fail, so the bump is noticed.</para>
|
||||
/// <para>SHARED SOURCE (#580): this directory has no project. <c>Driver.AbCip</c> and <c>Driver.AbLegacy</c> each
|
||||
/// <para>SHARED SOURCE: this directory has no project. <c>Driver.AbCip</c> and <c>Driver.AbLegacy</c> each
|
||||
/// compile this file in (<c><Compile Include></c>), so each assembly has its own internal copy and its own
|
||||
/// counters. It must keep a <c>Libplctag*.cs</c> name — <c>LibplctagConfinementGuardTests</c> allows libplctag
|
||||
/// references only in such files.</para>
|
||||
@@ -35,7 +35,7 @@ internal static class LibplctagOrphanRelease
|
||||
private const BindingFlags Private = BindingFlags.NonPublic | BindingFlags.Instance;
|
||||
|
||||
private static readonly FieldInfo? WrapperField = typeof(Tag).GetField("_tag", Private);
|
||||
// Final review M2: the field must also still be an int. A wrapper that retyped it (IntPtr, long) would make
|
||||
// The field must also still be an int. A wrapper that retyped it (IntPtr, long) would make
|
||||
// the cast in HandleOf throw; treating it as unreadable instead degrades to the rooting fallback ("keep
|
||||
// alive"), which the pin test reports.
|
||||
private static readonly FieldInfo? HandleField =
|
||||
@@ -57,6 +57,8 @@ internal static class LibplctagOrphanRelease
|
||||
|
||||
/// <summary>The native handle the wrapper holds (0 when none was ever created, or unreadable). Never throws:
|
||||
/// callers run it inside a catch block, where an exception would replace the failure being handled.</summary>
|
||||
/// <param name="tag">The libplctag wrapper whose handle is read.</param>
|
||||
/// <returns>The native handle, or 0 when none exists or it cannot be read.</returns>
|
||||
internal static int HandleOf(Tag tag)
|
||||
{
|
||||
try
|
||||
@@ -70,7 +72,7 @@ internal static class LibplctagOrphanRelease
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Clears the wrapper's handle after a destroy, so a repeat release is a no-op (review r1 M2).</summary>
|
||||
/// <summary>Clears the wrapper's handle after a destroy, so a repeat release is a no-op.</summary>
|
||||
private static void ClearHandle(Tag tag)
|
||||
{
|
||||
if (HandleField is not null && WrapperField!.GetValue(tag) is { } wrapper) HandleField.SetValue(wrapper, 0);
|
||||
@@ -81,6 +83,9 @@ internal static class LibplctagOrphanRelease
|
||||
/// Read/Write, which initialise on demand). If it fails and leaves the tag uninitialised, the native tag it
|
||||
/// created is unregistered and destroyed before the failure propagates.
|
||||
/// </summary>
|
||||
/// <param name="tag">The tag the operation may initialise.</param>
|
||||
/// <param name="op">The libplctag operation to run.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
internal static async Task RunAsync(Tag tag, Func<Task> op)
|
||||
{
|
||||
try
|
||||
@@ -112,15 +117,19 @@ internal static class LibplctagOrphanRelease
|
||||
/// Destroys the native tag behind an UNINITIALISED wrapper, unregistering its callback first. No-op for an
|
||||
/// initialised tag (its own <c>Dispose</c> handles it) or one that never got a handle. Never throws.
|
||||
/// </summary>
|
||||
/// <param name="tag">The libplctag wrapper to release when orphaned.</param>
|
||||
/// <returns>The status the native tag held just before it was destroyed (what <c>GetStatus()</c> would have
|
||||
/// reported), or null when nothing was destroyed.</returns>
|
||||
internal static Status? ReleaseIfOrphaned(Tag tag) => ReleaseIfOrphaned(tag, CanReadHandles);
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="ReleaseIfOrphaned(Tag)"/> with the handle-readability decision passed in, so a test can take the
|
||||
/// rooting fallback (review r1 M1) without a process-wide switch (Gitea #581). Production always passes
|
||||
/// rooting fallback without a process-wide switch. Production always passes
|
||||
/// <see cref="CanReadHandles"/>.
|
||||
/// </summary>
|
||||
/// <param name="tag">The libplctag wrapper to release when orphaned.</param>
|
||||
/// <param name="canReadHandles">Whether the wrapper's native handle can be read; when false the tag is rooted instead.</param>
|
||||
/// <returns>The status the native tag held just before it was destroyed, or null when nothing was destroyed.</returns>
|
||||
internal static Status? ReleaseIfOrphaned(Tag tag, bool canReadHandles)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -2,7 +2,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
|
||||
/// <summary>
|
||||
/// The single <see cref="AbStatus"/> to OPC UA StatusCode table shared by <c>AbCipStatusMapper</c> and
|
||||
/// <c>AbLegacyStatusMapper</c> (#580) — the two drivers used to carry identical copies.
|
||||
/// <c>AbLegacyStatusMapper</c> — the two drivers used to carry identical copies.
|
||||
/// </summary>
|
||||
public static class AbOpcUaStatusMap
|
||||
{
|
||||
|
||||
@@ -2,6 +2,10 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
|
||||
public static class AbStatusCodes
|
||||
{
|
||||
/// <summary>Maps a CIP general status (and optional extended status words) to an <see cref="AbStatus"/>.</summary>
|
||||
/// <param name="general">The CIP general status byte.</param>
|
||||
/// <param name="extended">The extended status words, if any.</param>
|
||||
/// <returns>The mapped status.</returns>
|
||||
public static AbStatus FromCip(byte general, ReadOnlySpan<ushort> extended) => general switch
|
||||
{
|
||||
0x00 or 0x0B => AbStatus.Ok,
|
||||
@@ -18,6 +22,10 @@ public static class AbStatusCodes
|
||||
_ => AbStatus.BadReply,
|
||||
};
|
||||
|
||||
/// <summary>Maps a PCCC status byte (and optional extended status) to an <see cref="AbStatus"/>.</summary>
|
||||
/// <param name="sts">The PCCC STS byte.</param>
|
||||
/// <param name="extSts">The extended status byte, when present.</param>
|
||||
/// <returns>The mapped status.</returns>
|
||||
public static AbStatus FromPccc(byte sts, byte? extSts) => sts switch
|
||||
{
|
||||
0x00 => AbStatus.Ok,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
|
||||
|
||||
/// <summary>Thrown by both AB transports. <see cref="Status"/> drives OPC UA status mapping and the
|
||||
/// driver's "controller answered, don't throttle the device" classification (05/STAB-8).</summary>
|
||||
/// driver's "controller answered, don't throttle the device" classification.</summary>
|
||||
public class AbTransportException(AbStatus status, string message, Exception? inner = null)
|
||||
: Exception(message, inner)
|
||||
{
|
||||
/// <summary>The AB status that classifies this failure.</summary>
|
||||
public AbStatus Status { get; } = status;
|
||||
}
|
||||
|
||||
@@ -12,9 +12,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
/// only by the <i>next</i> send; the failing call throws its own exception. Caller cancellation never
|
||||
/// faults the session (B2), so it never causes a reconnect. A connected reply saying the target no longer
|
||||
/// holds the class-3 connection (general 0x01, extended 0x0107 / 0x0203 — the session itself still up) retires
|
||||
/// the pair at once, whether a request or a keep-alive got it, so the next send reopens it (Gitea #566).</para>
|
||||
/// the pair at once, whether a request or a keep-alive got it, so the next send reopens it.</para>
|
||||
/// <para><b>Bounded.</b> Every <see cref="SendAsync"/> is bounded even if the caller passes no token
|
||||
/// (ruling R9-3): opening (including the throttle wait and any wait behind another opener) is bounded by
|
||||
/// — opening (including the throttle wait and any wait behind another opener) is bounded by
|
||||
/// <c>MinForwardOpenInterval + 4 × Timeout</c> (connect, RegisterSession, Forward Open, fallback Forward
|
||||
/// Open), and the exchange by <c>2 × Timeout</c> (waiting behind one in-flight exchange, then its own).
|
||||
/// Exceeding either surfaces as <see cref="AbTransportException"/>(<see cref="AbStatus.Timeout"/>).</para>
|
||||
@@ -52,6 +52,7 @@ public sealed class CipConnection : ICipChannel, IAsyncDisposable
|
||||
private int _keepAlivesSent;
|
||||
private int _connectionSerial = Random.Shared.Next(1, ushort.MaxValue);
|
||||
|
||||
/// <summary>Creates a connection for one device; nothing is opened until the first send.</summary>
|
||||
/// <param name="key">The device; <see cref="DeviceKey.CipPath"/> is the route (ignored for Direct).</param>
|
||||
/// <param name="options">Routing, sizes, timeouts and keep-alive settings.</param>
|
||||
/// <param name="throttle">The device's process-lifetime Forward Open throttle.</param>
|
||||
@@ -91,6 +92,7 @@ public sealed class CipConnection : ICipChannel, IAsyncDisposable
|
||||
private TimeSpan OpenBudget => _o.MinForwardOpenInterval + 4 * _o.Timeout;
|
||||
private TimeSpan ExchangeBudget => 2 * _o.Timeout;
|
||||
|
||||
/// <inheritdoc />
|
||||
public int MaxRequestBytes => _o.Routing switch
|
||||
{
|
||||
CipRouting.Connected => (Volatile.Read(ref _open)?.NegotiatedSize ?? Math.Min(_o.ConnectionSize, UnconnectedMessageLimit)) - 2,
|
||||
@@ -98,6 +100,7 @@ public sealed class CipConnection : ICipChannel, IAsyncDisposable
|
||||
_ => UnconnectedMessageLimit,
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ReadOnlyMemory<byte>> SendAsync(ReadOnlyMemory<byte> cipRequest, CancellationToken cancellationToken)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
@@ -140,7 +143,7 @@ public sealed class CipConnection : ICipChannel, IAsyncDisposable
|
||||
return pending.WaitAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>The shared open. Bounded by <see cref="OpenBudget"/> (ruling R9-3); its outcome, success or
|
||||
/// <summary>The shared open. Bounded by <see cref="OpenBudget"/>; its outcome, success or
|
||||
/// failure, goes to every caller waiting on it.</summary>
|
||||
private async Task RunOpenAsync(TaskCompletionSource<Open> result)
|
||||
{
|
||||
@@ -222,7 +225,7 @@ public sealed class CipConnection : ICipChannel, IAsyncDisposable
|
||||
var frame = ConnectionManagerServices.ForwardOpen(p);
|
||||
_diagnostics.CountForwardOpen();
|
||||
var raw = await client.SendRRDataAsync(frame, _o.Timeout, ct).ConfigureAwait(false);
|
||||
_diagnostics.NotifyExchange(frame, raw); // evidence tap: the granted RPIs / connection ids (#566 gate)
|
||||
_diagnostics.NotifyExchange(frame, raw); // evidence tap: the granted RPIs / connection ids
|
||||
return CipReply.Parse(raw);
|
||||
}
|
||||
|
||||
@@ -270,7 +273,7 @@ public sealed class CipConnection : ICipChannel, IAsyncDisposable
|
||||
/// The target answered over a session that is still up, but says the class-3 connection itself is gone (it
|
||||
/// timed the connection out, or was restarted / re-downloaded and forgot it). Every later connected request
|
||||
/// would get the same answer while <see cref="EncapsulationClient.IsConnected"/> stays true, so nothing else
|
||||
/// would ever reopen it (Gitea #566): retire the pair now, so the next send reopens through the throttle.
|
||||
/// would ever reopen it: retire the pair now, so the next send reopens through the throttle.
|
||||
/// No Forward Close — the target has just said there is nothing to close.
|
||||
/// </summary>
|
||||
private async Task RetireClosedByTargetAsync(Open open)
|
||||
@@ -333,6 +336,7 @@ public sealed class CipConnection : ICipChannel, IAsyncDisposable
|
||||
|
||||
/// <summary>Stops the keep-alive, sends Forward Close (Connected only) and closes the session, whose
|
||||
/// own dispose sends UnRegisterSession. Later sends throw <see cref="AbStatus.Abort"/>.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposeStarted, 1) != 0) return;
|
||||
|
||||
@@ -7,6 +7,8 @@ public static class CipPath
|
||||
{
|
||||
/// <summary>Route path from "port,link[,port,link…]". Links that are not a byte value are IP
|
||||
/// addresses, encoded as extended link-address port segments.</summary>
|
||||
/// <param name="cipPath">The route as comma-separated port,link pairs; blank yields an empty route.</param>
|
||||
/// <returns>The encoded route path segments.</returns>
|
||||
public static byte[] EncodeRoute(string cipPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(cipPath)) return [];
|
||||
@@ -47,6 +49,8 @@ public static class CipPath
|
||||
}
|
||||
|
||||
/// <summary>Splits a trailing purely numeric member ("Tag.5") into the tag name and bit index.</summary>
|
||||
/// <param name="tagName">The tag name, optionally ending in a numeric bit selector.</param>
|
||||
/// <returns>The tag name without the selector and the bit index, or a null bit when there is no selector.</returns>
|
||||
public static (string Name, int? Bit) SplitBitSelector(string tagName)
|
||||
{
|
||||
var dot = tagName.LastIndexOf('.');
|
||||
@@ -59,6 +63,9 @@ public static class CipPath
|
||||
return (tagName, null);
|
||||
}
|
||||
|
||||
/// <summary>Encodes a symbolic tag name (members and subscripts) as ANSI extended symbol segments.</summary>
|
||||
/// <param name="tagName">The tag name, without any bit selector.</param>
|
||||
/// <returns>The encoded symbolic path segments.</returns>
|
||||
/// <exception cref="ArgumentException">The name is empty, has an empty member, an over-long (> 255) or
|
||||
/// non-ASCII member, an unterminated / non-numeric subscript, or ends in a bit selector.</exception>
|
||||
public static byte[] EncodeSymbolic(string tagName)
|
||||
@@ -118,6 +125,10 @@ public static class CipPath
|
||||
yield return name[start..];
|
||||
}
|
||||
|
||||
/// <summary>Encodes a logical class and instance path (8- or 16-bit segments as the values require).</summary>
|
||||
/// <param name="cls">The CIP class ID.</param>
|
||||
/// <param name="instance">The CIP instance ID.</param>
|
||||
/// <returns>The encoded class/instance path segments.</returns>
|
||||
public static byte[] EncodeClassInstance(ushort cls, uint instance)
|
||||
{
|
||||
var w = new CipWriter();
|
||||
@@ -129,6 +140,8 @@ public static class CipPath
|
||||
}
|
||||
|
||||
/// <summary>Prefixes the path with its size in 16-bit words.</summary>
|
||||
/// <param name="segments">The encoded path segments.</param>
|
||||
/// <returns>The size byte followed by the segments.</returns>
|
||||
/// <exception cref="ArgumentException">The segments are not a whole number of 16-bit words, or exceed
|
||||
/// the 255-word maximum the one-byte size field can carry.</exception>
|
||||
public static byte[] WithPathSize(byte[] segments)
|
||||
|
||||
@@ -7,6 +7,7 @@ public sealed record CipReply(byte Service, byte GeneralStatus, ushort[] Extende
|
||||
{
|
||||
// Record equality over an array / ReadOnlyMemory is reference equality; two parses of the same wire
|
||||
// bytes must compare equal, so compare the extended-status words and the data by content.
|
||||
/// <inheritdoc />
|
||||
public bool Equals(CipReply? other) =>
|
||||
other is not null
|
||||
&& Service == other.Service
|
||||
@@ -14,6 +15,7 @@ public sealed record CipReply(byte Service, byte GeneralStatus, ushort[] Extende
|
||||
&& ExtendedStatus.AsSpan().SequenceEqual(other.ExtendedStatus)
|
||||
&& Data.Span.SequenceEqual(other.Data.Span);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int GetHashCode()
|
||||
{
|
||||
var h = new HashCode();
|
||||
@@ -24,8 +26,12 @@ public sealed record CipReply(byte Service, byte GeneralStatus, ushort[] Extende
|
||||
return h.ToHashCode();
|
||||
}
|
||||
|
||||
/// <summary>The reply status mapped from the general and extended status words.</summary>
|
||||
public AbStatus Status => AbStatusCodes.FromCip(GeneralStatus, ExtendedStatus);
|
||||
|
||||
/// <summary>Parses a raw message-router reply.</summary>
|
||||
/// <param name="raw">The raw reply bytes.</param>
|
||||
/// <returns>The parsed reply.</returns>
|
||||
public static CipReply Parse(ReadOnlyMemory<byte> raw)
|
||||
{
|
||||
var r = new CipReader(raw.Span);
|
||||
|
||||
@@ -7,9 +7,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
/// read-only differential harness.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Instance-scoped (Gitea #581): every pool — and every connection a test builds on its own — has its own, so
|
||||
/// Instance-scoped: every pool — and every connection a test builds on its own — has its own, so
|
||||
/// no process-wide mutable state ships in the transport, and two tests can never move each other's counts. The
|
||||
/// open-attempt limit is scoped to one <see cref="DeviceKey"/> (Gitea #566), so limiting the press under test
|
||||
/// open-attempt limit is scoped to one <see cref="DeviceKey"/>, so limiting the press under test
|
||||
/// can never refuse an open to any other device in the process.
|
||||
/// </remarks>
|
||||
internal sealed class ConnectionDiagnostics
|
||||
@@ -62,11 +62,17 @@ internal sealed class ConnectionDiagnostics
|
||||
Interlocked.Exchange(ref _forwardClosesAcked, 0);
|
||||
}
|
||||
|
||||
/// <summary>Counts one Forward Open frame sent.</summary>
|
||||
internal void CountForwardOpen() => Interlocked.Increment(ref _forwardOpens);
|
||||
/// <summary>Counts one Forward Close request put on the wire.</summary>
|
||||
internal void CountForwardCloseSent() => Interlocked.Increment(ref _forwardClosesSent);
|
||||
/// <summary>Counts one Forward Close that was acknowledged within its budget.</summary>
|
||||
internal void CountForwardCloseAcked() => Interlocked.Increment(ref _forwardClosesAcked);
|
||||
/// <summary>Counts one channel send.</summary>
|
||||
internal void CountRequest() => Interlocked.Increment(ref _requestsSent);
|
||||
/// <summary>Counts one open parked behind a still-closing predecessor.</summary>
|
||||
internal void CountPredecessorWait() => Interlocked.Increment(ref _predecessorWaits);
|
||||
/// <summary>Counts one connection retired because the target closed the CIP connection.</summary>
|
||||
internal void CountClosedByTarget() => Interlocked.Increment(ref _connectionsClosedByTarget);
|
||||
|
||||
/// <summary>Round-trip statistics of the exchanges completed since the last <see cref="ResetExchangeStats"/>.</summary>
|
||||
@@ -82,6 +88,7 @@ internal sealed class ConnectionDiagnostics
|
||||
}
|
||||
|
||||
/// <summary>Snapshot of the round-trip statistics.</summary>
|
||||
/// <returns>The count and min/max/mean round-trip times in milliseconds.</returns>
|
||||
public ExchangeStats ReadExchangeStats()
|
||||
{
|
||||
var count = Interlocked.Read(ref _exchangeCount);
|
||||
@@ -92,12 +99,15 @@ internal sealed class ConnectionDiagnostics
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evidence tap for the gate harness (#582 / #572 / #566): sees the CIP request and the raw CIP reply of every
|
||||
/// Evidence tap for the gate harness: sees the CIP request and the raw CIP reply of every
|
||||
/// completed exchange, Forward Opens included (their replies carry the granted RPIs). Null in production; set only by the read-only differential harness. A throwing
|
||||
/// observer is swallowed so it can never affect a request.
|
||||
/// </summary>
|
||||
internal Action<ReadOnlyMemory<byte>, ReadOnlyMemory<byte>>? ExchangeObserver { get; set; }
|
||||
|
||||
/// <summary>Passes a completed exchange to <see cref="ExchangeObserver"/>, swallowing any observer fault.</summary>
|
||||
/// <param name="request">The CIP request bytes.</param>
|
||||
/// <param name="reply">The raw CIP reply bytes.</param>
|
||||
internal void NotifyExchange(ReadOnlyMemory<byte> request, ReadOnlyMemory<byte> reply)
|
||||
{
|
||||
var observer = ExchangeObserver;
|
||||
@@ -106,6 +116,8 @@ internal sealed class ConnectionDiagnostics
|
||||
catch { /* evidence only */ }
|
||||
}
|
||||
|
||||
/// <summary>Records the round-trip time of one completed exchange.</summary>
|
||||
/// <param name="ticks">The elapsed Stopwatch ticks.</param>
|
||||
internal void RecordExchange(long ticks)
|
||||
{
|
||||
Interlocked.Increment(ref _exchangeCount);
|
||||
@@ -121,9 +133,12 @@ internal sealed class ConnectionDiagnostics
|
||||
/// Caps the open attempts to <paramref name="key"/> at <paramref name="attempts"/> until the returned scope is
|
||||
/// disposed. An attempt beyond the cap fails with <see cref="AbStatus.Abort"/> before any I/O — no TCP connect,
|
||||
/// no Forward Open — so a press that refuses or drops the connection sees exactly the budgeted attempts instead
|
||||
/// of one per remaining operation. Only the read-only differential harness (Task 18/21) sets one; one limit per
|
||||
/// of one per remaining operation. Only the read-only differential harness sets one; one limit per
|
||||
/// key at a time.
|
||||
/// </summary>
|
||||
/// <param name="key">The device whose open attempts are capped.</param>
|
||||
/// <param name="attempts">The number of open attempts allowed.</param>
|
||||
/// <returns>A scope that lifts the cap when disposed.</returns>
|
||||
public OpenAttemptLimit LimitOpenAttempts(DeviceKey key, int attempts)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
@@ -138,9 +153,13 @@ internal sealed class ConnectionDiagnostics
|
||||
}
|
||||
|
||||
/// <summary>True while a limit is in force for <paramref name="key"/>.</summary>
|
||||
/// <param name="key">The device to check.</param>
|
||||
/// <returns><see langword="true"/> when a limit is in force; otherwise <see langword="false"/>.</returns>
|
||||
public bool HasOpenAttemptLimit(DeviceKey key) { lock (_lock) return _limits.ContainsKey(key); }
|
||||
|
||||
/// <summary>Counts one open attempt to <paramref name="key"/>; null when it may proceed, else why it may not.</summary>
|
||||
/// <param name="key">The device being opened.</param>
|
||||
/// <returns>Null when the attempt may proceed; otherwise the refusal reason.</returns>
|
||||
internal string? AdmitOpenAttempt(DeviceKey key)
|
||||
{
|
||||
var attempt = Interlocked.Increment(ref _openAttempts);
|
||||
@@ -164,15 +183,20 @@ internal sealed class ConnectionDiagnostics
|
||||
{
|
||||
private int _attempts;
|
||||
|
||||
/// <summary>The device this limit applies to.</summary>
|
||||
public DeviceKey Key { get; } = key;
|
||||
|
||||
/// <summary>The number of open attempts allowed.</summary>
|
||||
public int Budget { get; } = budget;
|
||||
|
||||
/// <summary>Attempts counted against this limit, the refused ones included.</summary>
|
||||
public int Attempts => Volatile.Read(ref _attempts);
|
||||
|
||||
/// <summary>Counts one attempt against the budget.</summary>
|
||||
/// <returns><see langword="true"/> while the attempt is within the budget.</returns>
|
||||
internal bool TryTake() => Interlocked.Increment(ref _attempts) <= Budget;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose() => owner.Remove(this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,10 @@ public sealed record ForwardOpenReply(uint OTConnectionId, uint TOConnectionId,
|
||||
/// diagnostics) read <see cref="GeneralStatus"/> / <see cref="ExtendedStatus"/> directly.</summary>
|
||||
public sealed class ForwardOpenRefusedException : AbTransportException
|
||||
{
|
||||
/// <summary>Creates the exception for a refused Forward Open.</summary>
|
||||
/// <param name="status">The mapped transport status.</param>
|
||||
/// <param name="generalStatus">The CIP general status of the refusal.</param>
|
||||
/// <param name="extendedStatus">The CIP extended-status words of the refusal.</param>
|
||||
public ForwardOpenRefusedException(AbStatus status, byte generalStatus, IReadOnlyList<ushort> extendedStatus)
|
||||
: base(status, $"Forward Open refused: general 0x{generalStatus:X2}, extended {string.Join(",", extendedStatus.Select(e => $"0x{e:X4}"))}.")
|
||||
{
|
||||
@@ -37,6 +41,10 @@ public static class ConnectionManagerServices
|
||||
/// Close, Unconnected Send) carries 0x05, and the capture wins.</summary>
|
||||
public const byte TimeoutTicks = 0x05;
|
||||
|
||||
/// <summary>Wraps an embedded request in an Unconnected Send to the Connection Manager.</summary>
|
||||
/// <param name="embedded">The embedded CIP request.</param>
|
||||
/// <param name="route">The encoded route path to the target.</param>
|
||||
/// <returns>The encoded Unconnected Send request.</returns>
|
||||
public static byte[] UnconnectedSend(ReadOnlySpan<byte> embedded, ReadOnlySpan<byte> route)
|
||||
{
|
||||
var w = new CipWriter().U8(0x52).U8(2).Bytes(CipPath.ConnectionManager)
|
||||
@@ -45,6 +53,9 @@ public static class ConnectionManagerServices
|
||||
return w.U8((byte)(route.Length / 2)).U8(0).Bytes(route).ToArray();
|
||||
}
|
||||
|
||||
/// <summary>Builds a (Large) Forward Open request.</summary>
|
||||
/// <param name="p">The Forward Open parameters.</param>
|
||||
/// <returns>The encoded Forward Open request.</returns>
|
||||
public static byte[] ForwardOpen(ForwardOpenParams p)
|
||||
{
|
||||
if (p.ConnectionSize < 1 || p.ConnectionSize > (p.Large ? 0xFFFF : 0x1FF))
|
||||
@@ -67,6 +78,10 @@ public static class ConnectionManagerServices
|
||||
else w.U16((ushort)(0x4200 | (p.ConnectionSize & 0x1FF)));
|
||||
}
|
||||
|
||||
/// <summary>Parses a Forward Open reply.</summary>
|
||||
/// <param name="reply">The CIP reply to parse.</param>
|
||||
/// <returns>The granted connection ids and API values.</returns>
|
||||
/// <exception cref="ForwardOpenRefusedException">The target refused the Forward Open.</exception>
|
||||
public static ForwardOpenReply ParseForwardOpenReply(CipReply reply)
|
||||
{
|
||||
if (reply.GeneralStatus != 0)
|
||||
@@ -79,6 +94,8 @@ public static class ConnectionManagerServices
|
||||
|
||||
/// <summary>True when a refused Large Forward Open should be retried as a regular one: general 0x08
|
||||
/// (Service Not Supported — pre-v20 Logix) or general 0x01 / extended 0x0109 (invalid connection size).</summary>
|
||||
/// <param name="r">The refused Forward Open reply.</param>
|
||||
/// <returns><see langword="true"/> when a regular Forward Open should be tried.</returns>
|
||||
public static bool ShouldFallBackToRegularOpen(CipReply r) =>
|
||||
r.GeneralStatus == 0x08
|
||||
|| (r.GeneralStatus == 0x01 && r.ExtendedStatus.Length > 0 && r.ExtendedStatus[0] == 0x0109);
|
||||
@@ -95,6 +112,8 @@ public static class ConnectionManagerServices
|
||||
/// found) or 0x0203 (connection timed out) — else null. Any other status, a tag-level one included, describes
|
||||
/// the request and leaves the connection alone.
|
||||
/// </summary>
|
||||
/// <param name="reply">The raw CIP reply bytes.</param>
|
||||
/// <returns>The extended status when the connection is gone; otherwise <see langword="null"/>.</returns>
|
||||
public static ushort? IsConnectionGone(ReadOnlySpan<byte> reply)
|
||||
{
|
||||
// Reply header: service | reserved | general status | extended-status word count | words...
|
||||
@@ -103,6 +122,12 @@ public static class ConnectionManagerServices
|
||||
return extended is TargetConnectionNotFound or ConnectionTimedOut ? extended : null;
|
||||
}
|
||||
|
||||
/// <summary>Builds a Forward Close request.</summary>
|
||||
/// <param name="connectionSerial">The connection serial number used at open.</param>
|
||||
/// <param name="vendorId">The originator vendor id used at open.</param>
|
||||
/// <param name="originatorSerial">The originator serial number used at open.</param>
|
||||
/// <param name="route">The encoded route path to the target.</param>
|
||||
/// <returns>The encoded Forward Close request.</returns>
|
||||
public static byte[] ForwardClose(ushort connectionSerial, ushort vendorId, uint originatorSerial, ReadOnlySpan<byte> route)
|
||||
{
|
||||
var connPath = new CipWriter().Bytes(route).Bytes(CipPath.MessageRouter).ToArray();
|
||||
|
||||
@@ -31,13 +31,14 @@ public sealed record ConnectionOptions
|
||||
private readonly byte _timeoutMultiplier = 1;
|
||||
private readonly TimeSpan? _keepAliveInterval;
|
||||
|
||||
/// <summary>How requests reach the target's message router.</summary>
|
||||
public CipRouting Routing { get; init; } = CipRouting.Connected;
|
||||
|
||||
/// <summary>Pack queued requests into Multiple Service Packets (the scheduler's switch).</summary>
|
||||
public bool AllowPacking { get; init; }
|
||||
|
||||
/// <summary>Bound on each single exchange (TCP connect, RegisterSession, Forward Open, request).
|
||||
/// Must be finite and positive (ruling R9-2): an infinite timeout would let an abandoned exchange
|
||||
/// Must be finite and positive: an infinite timeout would let an abandoned exchange
|
||||
/// hold the session's single-flight gate forever.</summary>
|
||||
public TimeSpan Timeout
|
||||
{
|
||||
@@ -93,6 +94,7 @@ public sealed record ConnectionOptions
|
||||
: throw new ArgumentOutOfRangeException(nameof(KeepAliveInterval), value, "Keep-alive interval must be finite and positive.");
|
||||
}
|
||||
|
||||
/// <summary>The vendor id sent in the Forward Open originator identity.</summary>
|
||||
public ushort VendorId { get; init; } = 0x1337;
|
||||
|
||||
/// <summary>Random per process; together with the vendor id and connection serial it names a connection.</summary>
|
||||
|
||||
@@ -22,6 +22,7 @@ public sealed record DeviceKey(string Host, int Port, string CipPath, string Plc
|
||||
private static string Canonicalise(string? path) =>
|
||||
string.IsNullOrWhiteSpace(path) ? "" : string.Join(',', path.Split(',').Select(p => p.Trim()));
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Equals(DeviceKey? other) =>
|
||||
other is not null
|
||||
&& string.Equals(Host, other.Host, StringComparison.OrdinalIgnoreCase)
|
||||
@@ -29,6 +30,8 @@ public sealed record DeviceKey(string Host, int Port, string CipPath, string Plc
|
||||
&& string.Equals(CipPath, other.CipPath, StringComparison.Ordinal)
|
||||
&& string.Equals(PlcAttribute, other.PlcAttribute, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// <summary>Hashes the same fields used by <see cref="Equals(DeviceKey?)"/>.</summary>
|
||||
/// <inheritdoc />
|
||||
public override int GetHashCode() => HashCode.Combine(
|
||||
StringComparer.OrdinalIgnoreCase.GetHashCode(Host),
|
||||
Port,
|
||||
|
||||
@@ -16,6 +16,9 @@ public sealed class ForwardOpenThrottle
|
||||
private int _queued;
|
||||
private Task? _intervalWait; // the pending interval wait (test hook only)
|
||||
|
||||
/// <summary>Creates a throttle that spaces Forward Open attempts at least <paramref name="minInterval"/> apart.</summary>
|
||||
/// <param name="minInterval">The minimum gap between the starts of successive attempts; must be finite and non-negative.</param>
|
||||
/// <param name="time">The clock used to measure the interval.</param>
|
||||
/// <remarks>Internal: the only legitimate owner is <see cref="DeviceSessionPool"/>, which keeps exactly
|
||||
/// one per device. A second throttle for the same device would halve the guaranteed interval.</remarks>
|
||||
internal ForwardOpenThrottle(TimeSpan minInterval, TimeProvider time)
|
||||
@@ -39,6 +42,10 @@ public sealed class ForwardOpenThrottle
|
||||
/// throttled"). <paramref name="ct"/> is also passed to <paramref name="open"/>, which may ignore it:
|
||||
/// <see cref="CipConnection"/> does, running the attempt under its own open deadline so that an accepted
|
||||
/// Forward Open is never abandoned without a Forward Close.</summary>
|
||||
/// <typeparam name="T">The result type of the open attempt.</typeparam>
|
||||
/// <param name="open">The open attempt to run once the throttle allows it.</param>
|
||||
/// <param name="ct">Cancels the wait before the attempt starts.</param>
|
||||
/// <returns>The result of <paramref name="open"/>.</returns>
|
||||
public async Task<T> RunAsync<T>(Func<CancellationToken, Task<T>> open, CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(open);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
|
||||
|
||||
/// <summary>A path to a CIP message router: connected (Forward Open) or unconnected (UCMM). Task 9
|
||||
/// implements it; Task 10's scheduler and the native adapters consume it.</summary>
|
||||
/// <summary>A path to a CIP message router: connected (Forward Open) or unconnected (UCMM). The
|
||||
/// native CIP transport implements it; the scheduler and the native adapters consume it.</summary>
|
||||
public interface ICipChannel
|
||||
{
|
||||
/// <summary>Largest CIP request (service byte through end of data) one call may carry.</summary>
|
||||
@@ -9,5 +9,8 @@ public interface ICipChannel
|
||||
|
||||
/// <summary>Send one CIP request and return the raw CIP reply bytes (reply-service byte first).
|
||||
/// Throws <see cref="AbTransportException"/> on transport failure or timeout.</summary>
|
||||
/// <param name="cipRequest">The CIP request bytes, service byte through end of data.</param>
|
||||
/// <param name="cancellationToken">Token that cancels the in-flight request.</param>
|
||||
/// <returns>The raw CIP reply bytes, reply-service byte first.</returns>
|
||||
Task<ReadOnlyMemory<byte>> SendAsync(ReadOnlyMemory<byte> cipRequest, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ public static class MultipleServicePacket
|
||||
/// <summary>Service + path size + Message Router path.</summary>
|
||||
private const int HeaderSize = 6;
|
||||
|
||||
/// <summary>Builds a Multiple Service Packet carrying the given embedded requests.</summary>
|
||||
/// <param name="requests">The encoded embedded requests.</param>
|
||||
/// <returns>The encoded Multiple Service Packet.</returns>
|
||||
public static byte[] Build(IReadOnlyList<byte[]> requests)
|
||||
{
|
||||
var w = new CipWriter().U8(0x0A).U8(2).Bytes(CipPath.MessageRouter).U16((ushort)requests.Count);
|
||||
@@ -17,6 +20,8 @@ public static class MultipleServicePacket
|
||||
}
|
||||
|
||||
/// <summary>Exact byte size of an MSP carrying requests of the given sizes.</summary>
|
||||
/// <param name="requestSizes">The byte size of each embedded request.</param>
|
||||
/// <returns>The total packet size in bytes.</returns>
|
||||
public static int BuildSize(IReadOnlyList<int> requestSizes)
|
||||
{
|
||||
var total = HeaderSize + 2 + 2 * requestSizes.Count;
|
||||
@@ -25,6 +30,9 @@ public static class MultipleServicePacket
|
||||
}
|
||||
|
||||
/// <summary>Splits an MSP reply (the outer reply's data) into the embedded replies.</summary>
|
||||
/// <param name="reply">The outer Multiple Service Packet reply.</param>
|
||||
/// <param name="count">The number of embedded replies expected.</param>
|
||||
/// <returns>The embedded replies in request order.</returns>
|
||||
public static IReadOnlyList<CipReply> Parse(CipReply reply, int count)
|
||||
{
|
||||
var data = reply.Data;
|
||||
|
||||
@@ -38,6 +38,7 @@ public sealed class RequestScheduler : IAsyncDisposable
|
||||
private CancellationTokenRegistration _registration;
|
||||
private bool _hasRegistration;
|
||||
|
||||
/// <summary>The encoded CIP request bytes.</summary>
|
||||
public byte[] Request { get; } = request;
|
||||
|
||||
/// <summary>True when this request must never share a Multiple Service Packet with another.</summary>
|
||||
@@ -45,10 +46,14 @@ public sealed class RequestScheduler : IAsyncDisposable
|
||||
|
||||
/// <summary>Expected whole-reply size (header included), or <see cref="UnknownReply"/>.</summary>
|
||||
public int ExpectedReplyBytes { get; } = expectedReplyBytes;
|
||||
/// <summary>Completes with the reply, a fault, or cancellation.</summary>
|
||||
public TaskCompletionSource<CipReply> Tcs { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
/// <summary>This request's node in the scheduler queue, or null once dequeued.</summary>
|
||||
public LinkedListNode<Pending>? Node { get; set; }
|
||||
|
||||
/// <summary>Stores the waiter's registration; disposes it at once if the waiter already completed.</summary>
|
||||
/// <param name="registration">The cancellation registration to track.</param>
|
||||
/// <param name="owner">The scheduler whose live-registration count is maintained.</param>
|
||||
public void SetRegistration(CancellationTokenRegistration registration, RequestScheduler owner)
|
||||
{
|
||||
lock (_gate)
|
||||
@@ -89,16 +94,25 @@ public sealed class RequestScheduler : IAsyncDisposable
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Completes the waiter with a reply, unless it already completed.</summary>
|
||||
/// <param name="reply">The reply.</param>
|
||||
/// <param name="owner">The owning scheduler.</param>
|
||||
public void Succeed(CipReply reply, RequestScheduler owner)
|
||||
{
|
||||
if (Finish(owner)) Tcs.TrySetResult(reply);
|
||||
}
|
||||
|
||||
/// <summary>Faults the waiter, unless it already completed.</summary>
|
||||
/// <param name="ex">The fault.</param>
|
||||
/// <param name="owner">The owning scheduler.</param>
|
||||
public void Fail(Exception ex, RequestScheduler owner)
|
||||
{
|
||||
if (Finish(owner)) Tcs.TrySetException(ex);
|
||||
}
|
||||
|
||||
/// <summary>Cancels the waiter, unless it already completed.</summary>
|
||||
/// <param name="ct">The token reported as the cancellation cause.</param>
|
||||
/// <param name="owner">The owning scheduler.</param>
|
||||
public void Cancel(CancellationToken ct, RequestScheduler owner)
|
||||
{
|
||||
if (Finish(owner)) Tcs.TrySetCanceled(ct);
|
||||
@@ -116,6 +130,9 @@ public sealed class RequestScheduler : IAsyncDisposable
|
||||
private int _liveRegistrations;
|
||||
private int _cancellationCallbacksRun;
|
||||
|
||||
/// <summary>Creates a scheduler and starts its pump over the channel.</summary>
|
||||
/// <param name="channel">The channel requests are sent on.</param>
|
||||
/// <param name="allowPacking">Whether requests may be combined into Multiple Service Packets.</param>
|
||||
public RequestScheduler(ICipChannel channel, bool allowPacking)
|
||||
{
|
||||
_channel = channel ?? throw new ArgumentNullException(nameof(channel));
|
||||
@@ -144,9 +161,10 @@ public sealed class RequestScheduler : IAsyncDisposable
|
||||
/// Packet: for requests whose reply can fill the whole connection.</param>
|
||||
/// <param name="expectedReplyBytes">Upper bound on this request's whole reply (header included) as
|
||||
/// it would sit inside a Multiple Service Packet reply. The packer budgets this against the
|
||||
/// connection size as well as the request bytes (#582), so a batch of small requests with large
|
||||
/// connection size as well as the request bytes, so a batch of small requests with large
|
||||
/// replies (STRINGs, arrays) is split instead of overflowing. Defaults to <see cref="StatusOnlyReply"/>;
|
||||
/// <see cref="UnknownReply"/> sends the request alone.</param>
|
||||
/// <returns>A task that completes with the CIP reply.</returns>
|
||||
public Task<CipReply> SendAsync(byte[] request, CancellationToken ct, bool exclusive = false,
|
||||
int expectedReplyBytes = StatusOnlyReply)
|
||||
{
|
||||
@@ -221,6 +239,8 @@ public sealed class RequestScheduler : IAsyncDisposable
|
||||
|
||||
/// <summary>Expected size of the MSP reply for the given embedded reply sizes: reply header, count,
|
||||
/// one offset per reply, then the replies themselves.</summary>
|
||||
/// <param name="replySizes">The sizes of the embedded replies.</param>
|
||||
/// <returns>The total size of the MSP reply in bytes.</returns>
|
||||
internal static int ReplyEnvelopeSize(IReadOnlyList<int> replySizes)
|
||||
{
|
||||
var total = ReplyHeaderBytes + 2 + 2 * replySizes.Count;
|
||||
@@ -324,6 +344,7 @@ public sealed class RequestScheduler : IAsyncDisposable
|
||||
|
||||
/// <summary>Stops the pump. In-flight and queued waiters fault with
|
||||
/// <see cref="AbTransportException"/> (<see cref="AbStatus.BadConnection"/>).</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
lock (_lock)
|
||||
|
||||
@@ -19,10 +19,13 @@ public sealed class DeviceSessionPool
|
||||
internal sealed class Entry(CipConnection connection, RequestScheduler scheduler, ConnectionOptions options,
|
||||
Task predecessorClosed)
|
||||
{
|
||||
/// <summary>The shared CIP connection for the device.</summary>
|
||||
public CipConnection Connection { get; } = connection;
|
||||
/// <summary>Completes when every earlier connection for this key has finished closing.</summary>
|
||||
public Task PredecessorClosed { get; } = predecessorClosed;
|
||||
/// <summary>The request scheduler serializing and packing traffic on <see cref="Connection"/>.</summary>
|
||||
public RequestScheduler Scheduler { get; } = scheduler;
|
||||
/// <summary>The options the connection was created with.</summary>
|
||||
public ConnectionOptions Options { get; } = options;
|
||||
public int RefCount; // under the pool lock
|
||||
public bool MismatchLogged; // under the pool lock
|
||||
@@ -35,6 +38,9 @@ public sealed class DeviceSessionPool
|
||||
private readonly TimeProvider _time;
|
||||
private volatile ILogger _logger;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="DeviceSessionPool"/> class.</summary>
|
||||
/// <param name="time">Time source; defaults to <see cref="TimeProvider.System"/>.</param>
|
||||
/// <param name="logger">Logger for mismatch and close-failure warnings; defaults to a no-op logger.</param>
|
||||
public DeviceSessionPool(TimeProvider? time = null, ILogger? logger = null)
|
||||
{
|
||||
_time = time ?? TimeProvider.System;
|
||||
@@ -43,6 +49,7 @@ public sealed class DeviceSessionPool
|
||||
|
||||
/// <summary>Give <see cref="Shared"/> a logger (it is built before any host exists), so its
|
||||
/// options-mismatch and close-failure warnings reach the service log. Call once at startup.</summary>
|
||||
/// <param name="loggerFactory">The factory used to create the shared pool's logger.</param>
|
||||
public static void ConfigureSharedLogger(ILoggerFactory loggerFactory)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(loggerFactory);
|
||||
@@ -61,6 +68,9 @@ public sealed class DeviceSessionPool
|
||||
/// <summary>Lease the shared connection for <paramref name="key"/>, creating it on first use. Performs
|
||||
/// no I/O: the connection opens on its first request. When the key already has an entry, that entry's
|
||||
/// options win; a differing <paramref name="options"/> is logged once per entry.</summary>
|
||||
/// <param name="key">The device to lease a connection for.</param>
|
||||
/// <param name="options">The options to open the connection with if the key has no entry yet.</param>
|
||||
/// <returns>A lease on the device's shared connection.</returns>
|
||||
public ConnectionLease Acquire(DeviceKey key, ConnectionOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(key);
|
||||
@@ -89,6 +99,10 @@ public sealed class DeviceSessionPool
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Drops one reference on <paramref name="entry"/>; the last release removes it and closes the connection.</summary>
|
||||
/// <param name="key">The device key the entry belongs to.</param>
|
||||
/// <param name="entry">The entry whose reference is released.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
internal async ValueTask ReleaseAsync(DeviceKey key, Entry entry)
|
||||
{
|
||||
TaskCompletionSource closed;
|
||||
@@ -128,6 +142,11 @@ public sealed class DeviceSessionPool
|
||||
/// connection. The connection is disposed even if the scheduler's dispose throws: otherwise its keep-alive
|
||||
/// would hold the press connection open forever. Never throws; each failure is logged.
|
||||
/// </summary>
|
||||
/// <param name="key">The device being closed (for logging).</param>
|
||||
/// <param name="scheduler">The request scheduler to dispose first.</param>
|
||||
/// <param name="connection">The connection to dispose after the scheduler.</param>
|
||||
/// <param name="logger">The logger that receives close failures.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
internal static async Task CloseAsync(DeviceKey key, IAsyncDisposable scheduler, IAsyncDisposable connection, ILogger logger)
|
||||
{
|
||||
try { await scheduler.DisposeAsync().ConfigureAwait(false); }
|
||||
@@ -152,6 +171,10 @@ public sealed class ConnectionLease : IAsyncDisposable
|
||||
private readonly DeviceSessionPool.Entry _entry;
|
||||
private int _disposed;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="ConnectionLease"/> class.</summary>
|
||||
/// <param name="pool">The owning pool.</param>
|
||||
/// <param name="key">The device key leased.</param>
|
||||
/// <param name="entry">The pool entry this lease references.</param>
|
||||
internal ConnectionLease(DeviceSessionPool pool, DeviceKey key, DeviceSessionPool.Entry entry)
|
||||
{
|
||||
_pool = pool;
|
||||
@@ -159,6 +182,7 @@ public sealed class ConnectionLease : IAsyncDisposable
|
||||
_entry = entry;
|
||||
}
|
||||
|
||||
/// <summary>The device this lease is on.</summary>
|
||||
public DeviceKey Key => _key;
|
||||
|
||||
/// <summary>The device's channel. Prefer <see cref="Scheduler"/>: it serializes and packs requests.</summary>
|
||||
@@ -167,13 +191,15 @@ public sealed class ConnectionLease : IAsyncDisposable
|
||||
/// <summary>The device's single request scheduler, shared by every lease on the key.</summary>
|
||||
public RequestScheduler Scheduler => _entry.Scheduler;
|
||||
|
||||
/// <summary>Releases the lease; further calls are no-ops.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public ValueTask DisposeAsync() =>
|
||||
Interlocked.Exchange(ref _disposed, 1) == 0 ? _pool.ReleaseAsync(_key, _entry) : ValueTask.CompletedTask;
|
||||
|
||||
/// <summary>
|
||||
/// Releases this lease without waiting for the teardown — the one release shape both native AB tag
|
||||
/// runtimes use from their synchronous <c>Dispose</c>, which the drivers call under a per-tag lock
|
||||
/// (Gitea #579). Idempotent, like <see cref="DisposeAsync"/>.
|
||||
/// runtimes use from their synchronous <c>Dispose</c>, which the drivers call under a per-tag lock.
|
||||
/// Idempotent, like <see cref="DisposeAsync"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Nothing is lost by not waiting. The release's bookkeeping — the ref-count drop and, for the
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Eip;
|
||||
/// <see cref="SemaphoreSlim"/>(1,1). The connected sequence count is advanced inside that gate, so
|
||||
/// concurrent callers can never put sequences on the wire out of order.</para>
|
||||
/// <para><b>Caller cancellation never faults the connection, and never misattributes a reply</b>
|
||||
/// (rulings R5-1/R5-2). The caller gets <see cref="OperationCanceledException"/> at once, but the
|
||||
/// The caller gets <see cref="OperationCanceledException"/> at once, but the
|
||||
/// abandoned exchange keeps reading <i>its own</i> reply in the background while still holding the
|
||||
/// gate, bounded by the original timeout. The reply is discarded and the connection stays healthy;
|
||||
/// if the timeout elapses first the connection faults exactly as a normal timeout does. Because the
|
||||
@@ -65,10 +65,18 @@ public sealed class EncapsulationClient : IAsyncDisposable
|
||||
/// <summary>Test seam: wraps an already-connected <paramref name="tcp"/> but routes all frame I/O through
|
||||
/// <paramref name="stream"/> (e.g. one whose write never completes), skipping RegisterSession. The TCP
|
||||
/// client is still what <see cref="Fault"/> closes and <see cref="IsConnected"/> consults.</summary>
|
||||
/// <param name="tcp">The already-connected TCP client.</param>
|
||||
/// <param name="stream">The stream through which all frame I/O is routed.</param>
|
||||
/// <returns>A client wrapping <paramref name="tcp"/> that performs I/O over <paramref name="stream"/>.</returns>
|
||||
internal static EncapsulationClient ForStream(TcpClient tcp, Stream stream) => new(tcp, stream);
|
||||
|
||||
/// <summary>Open the TCP connection and register an encapsulation session. <paramref name="timeout"/>
|
||||
/// bounds the TCP connect and the RegisterSession exchange separately.</summary>
|
||||
/// <param name="host">The host name or IP address of the controller.</param>
|
||||
/// <param name="port">The TCP port to connect to.</param>
|
||||
/// <param name="timeout">The bound applied to the TCP connect and the RegisterSession exchange separately.</param>
|
||||
/// <param name="ct">Token that cancels the operation.</param>
|
||||
/// <returns>A task that yields the connected client with a registered encapsulation session.</returns>
|
||||
public static async Task<EncapsulationClient> ConnectAsync(string host, int port, TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
ValidateTimeout(timeout);
|
||||
@@ -111,6 +119,10 @@ public sealed class EncapsulationClient : IAsyncDisposable
|
||||
}
|
||||
|
||||
/// <summary>Send one unconnected (UCMM) CIP request and return the CIP reply bytes.</summary>
|
||||
/// <param name="cip">The CIP request bytes.</param>
|
||||
/// <param name="timeout">The bound on the whole exchange.</param>
|
||||
/// <param name="ct">Token that cancels the wait for the reply.</param>
|
||||
/// <returns>A task that yields the CIP reply bytes.</returns>
|
||||
public async Task<ReadOnlyMemory<byte>> SendRRDataAsync(ReadOnlyMemory<byte> cip, TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
ValidateTimeout(timeout);
|
||||
@@ -122,6 +134,11 @@ public sealed class EncapsulationClient : IAsyncDisposable
|
||||
|
||||
/// <summary>Send one connected (class 3) CIP request over <paramref name="otConnectionId"/> and return
|
||||
/// the CIP reply bytes. The client owns the connected sequence count (review B1).</summary>
|
||||
/// <param name="otConnectionId">The originator-to-target connection id of the open class-3 connection.</param>
|
||||
/// <param name="cip">The CIP request bytes.</param>
|
||||
/// <param name="timeout">The bound on the whole exchange.</param>
|
||||
/// <param name="ct">Token that cancels the wait for the reply.</param>
|
||||
/// <returns>A task that yields the CIP reply bytes.</returns>
|
||||
public async Task<ReadOnlyMemory<byte>> SendUnitDataAsync(uint otConnectionId, ReadOnlyMemory<byte> cip,
|
||||
TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
@@ -140,6 +157,8 @@ public sealed class EncapsulationClient : IAsyncDisposable
|
||||
|
||||
/// <summary>The connected sequence count after <paramref name="current"/>: increments, wrapping
|
||||
/// 65535 → 1 (0 is never put on the wire).</summary>
|
||||
/// <param name="current">The current sequence count.</param>
|
||||
/// <returns>The next sequence count.</returns>
|
||||
internal static ushort NextSequence(ushort current) => current == ushort.MaxValue ? (ushort)1 : (ushort)(current + 1);
|
||||
|
||||
/// <summary>Acquire the gate, build the request (sequence assigned under the gate), then hand the
|
||||
@@ -175,7 +194,7 @@ public sealed class EncapsulationClient : IAsyncDisposable
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
// Caller abandoned the wait (R5-1). The exchange keeps draining its own reply under the
|
||||
// Caller abandoned the wait. The exchange keeps draining its own reply under the
|
||||
// gate and faults on its own timeout; observe its outcome so a fault is never unobserved.
|
||||
_ = exchange.ContinueWith(static t => _ = t.Exception, CancellationToken.None,
|
||||
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
|
||||
@@ -233,7 +252,7 @@ public sealed class EncapsulationClient : IAsyncDisposable
|
||||
}
|
||||
|
||||
/// <summary>Does <paramref name="frame"/> answer the request in flight? Unconnected replies match on
|
||||
/// command and sender context, tolerating a zeroed context (ab_server, R5-2); connected replies match
|
||||
/// command and sender context, tolerating a zeroed context (ab_server); connected replies match
|
||||
/// on the echoed sequence count. Anything else is skipped. A matching frame is fully validated, so an
|
||||
/// encapsulation error throws BadReply.</summary>
|
||||
private static bool TryMatch(byte[] frame, ushort cmd, bool connected, ulong ctx, ushort seq,
|
||||
@@ -296,6 +315,7 @@ public sealed class EncapsulationClient : IAsyncDisposable
|
||||
/// <summary>Best-effort UnRegisterSession, then close. Waits briefly for an in-flight exchange so the
|
||||
/// UnRegisterSession frame is never interleaved with another frame; if the gate is not free in time
|
||||
/// the socket is closed without it.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
|
||||
|
||||
@@ -35,14 +35,23 @@ public static class EncapsulationCodec
|
||||
/// ab_server's RegisterSession sanity check rejects any non-zero context with encapsulation status 1
|
||||
/// and a header-only reply, so a per-request context here failed every native session against the
|
||||
/// simulator. The reply is still matched — unconnected matching tolerates a zero context.</remarks>
|
||||
/// <returns>The encoded RegisterSession frame.</returns>
|
||||
public static byte[] RegisterSession() =>
|
||||
Finish(Header(EncapCommand.RegisterSession, 0, 0).U16(1).U16(0));
|
||||
|
||||
/// <summary>UnRegisterSession: header only, no data.</summary>
|
||||
/// <param name="session">The session handle returned by RegisterSession.</param>
|
||||
/// <param name="ctx">The sender context echoed in the reply.</param>
|
||||
/// <returns>The encoded UnRegisterSession frame.</returns>
|
||||
public static byte[] UnRegisterSession(uint session, ulong ctx) =>
|
||||
Finish(Header(EncapCommand.UnRegisterSession, session, ctx));
|
||||
|
||||
/// <summary>SendRRData (unconnected): Null Address item + Unconnected Data item carrying <paramref name="cip"/>.</summary>
|
||||
/// <param name="session">The session handle returned by RegisterSession.</param>
|
||||
/// <param name="ctx">The sender context echoed in the reply.</param>
|
||||
/// <param name="cip">The CIP request carried in the Unconnected Data item.</param>
|
||||
/// <param name="timeoutSeconds">The encapsulation timeout, in seconds.</param>
|
||||
/// <returns>The encoded SendRRData frame.</returns>
|
||||
public static byte[] SendRRData(uint session, ulong ctx, ReadOnlySpan<byte> cip, ushort timeoutSeconds) =>
|
||||
Finish(Header(EncapCommand.SendRRData, session, ctx)
|
||||
.U32(0).U16(timeoutSeconds).U16(2)
|
||||
@@ -51,6 +60,12 @@ public static class EncapsulationCodec
|
||||
|
||||
/// <summary>SendUnitData (connected): Connected Address item + Connected Data item carrying the
|
||||
/// sequence count followed by <paramref name="cip"/>.</summary>
|
||||
/// <param name="session">The session handle returned by RegisterSession.</param>
|
||||
/// <param name="ctx">The sender context echoed in the reply.</param>
|
||||
/// <param name="connectionId">The connection id of the Forward Open connection.</param>
|
||||
/// <param name="sequence">The connected-data sequence count.</param>
|
||||
/// <param name="cip">The CIP request carried in the Connected Data item.</param>
|
||||
/// <returns>The encoded SendUnitData frame.</returns>
|
||||
public static byte[] SendUnitData(uint session, ulong ctx, uint connectionId, ushort sequence, ReadOnlySpan<byte> cip) =>
|
||||
Finish(Header(EncapCommand.SendUnitData, session, ctx)
|
||||
.U32(0).U16(0).U16(2)
|
||||
@@ -65,6 +80,8 @@ public static class EncapsulationCodec
|
||||
}
|
||||
|
||||
/// <summary>Read the 24-byte header at the start of <paramref name="b"/>.</summary>
|
||||
/// <param name="b">The bytes beginning with the encapsulation header.</param>
|
||||
/// <returns>The decoded header.</returns>
|
||||
public static EncapsulationHeader ReadHeader(ReadOnlySpan<byte> b)
|
||||
{
|
||||
var r = new CipReader(b);
|
||||
@@ -76,6 +93,9 @@ public static class EncapsulationCodec
|
||||
/// the echoed sequence count. RegisterSession / UnRegisterSession replies return an empty payload.
|
||||
/// Throws <see cref="AbTransportException"/>(<see cref="AbStatus.BadReply"/>) on a non-zero
|
||||
/// encapsulation status, a length mismatch, a truncated item list or a missing data item.</summary>
|
||||
/// <param name="frame">The complete reply frame, header included.</param>
|
||||
/// <param name="expectConnected">True to expect a connected data item; false for an unconnected one.</param>
|
||||
/// <returns>The header, the CIP payload, and the echoed sequence count (0 for unconnected replies).</returns>
|
||||
public static (EncapsulationHeader Header, ReadOnlyMemory<byte> Cip, ushort Sequence) ParseReply(
|
||||
ReadOnlyMemory<byte> frame, bool expectConnected)
|
||||
{
|
||||
|
||||
@@ -8,11 +8,28 @@ public static class LogixServices
|
||||
private static CipWriter Start(byte service, ReadOnlySpan<byte> path) =>
|
||||
new CipWriter().U8(service).U8((byte)(path.Length / 2)).Bytes(path);
|
||||
|
||||
/// <summary>Builds a Read Tag request.</summary>
|
||||
/// <param name="path">The encoded tag path.</param>
|
||||
/// <param name="elements">The number of elements to read.</param>
|
||||
/// <returns>The encoded CIP request bytes.</returns>
|
||||
public static byte[] ReadTag(ReadOnlySpan<byte> path, ushort elements) => Start(0x4C, path).U16(elements).ToArray();
|
||||
|
||||
/// <summary>Builds a Read Tag Fragmented request.</summary>
|
||||
/// <param name="path">The encoded tag path.</param>
|
||||
/// <param name="elements">The number of elements to read.</param>
|
||||
/// <param name="offset">The byte offset into the tag data at which to resume.</param>
|
||||
/// <returns>The encoded CIP request bytes.</returns>
|
||||
public static byte[] ReadTagFragmented(ReadOnlySpan<byte> path, ushort elements, uint offset) =>
|
||||
Start(0x52, path).U16(elements).U32(offset).ToArray();
|
||||
|
||||
/// <summary>Builds a Write Tag request.</summary>
|
||||
/// <param name="path">The encoded tag path.</param>
|
||||
/// <param name="type">The Logix data type code.</param>
|
||||
/// <param name="handle">The structure handle; required when <paramref name="type"/> is a structure.</param>
|
||||
/// <param name="elements">The number of elements to write.</param>
|
||||
/// <param name="data">The encoded value bytes.</param>
|
||||
/// <returns>The encoded CIP request bytes.</returns>
|
||||
/// <exception cref="ArgumentException">A structure write was requested without a handle.</exception>
|
||||
public static byte[] WriteTag(ReadOnlySpan<byte> path, ushort type, ushort? handle, ushort elements, ReadOnlySpan<byte> data)
|
||||
{
|
||||
var w = Start(0x4D, path).U16(type);
|
||||
@@ -20,6 +37,15 @@ public static class LogixServices
|
||||
return w.U16(elements).Bytes(data).ToArray();
|
||||
}
|
||||
|
||||
/// <summary>Builds a Write Tag Fragmented request.</summary>
|
||||
/// <param name="path">The encoded tag path.</param>
|
||||
/// <param name="type">The Logix data type code.</param>
|
||||
/// <param name="handle">The structure handle; required when <paramref name="type"/> is a structure.</param>
|
||||
/// <param name="elements">The number of elements to write.</param>
|
||||
/// <param name="offset">The byte offset into the tag data at which this fragment starts.</param>
|
||||
/// <param name="data">The encoded value bytes of this fragment.</param>
|
||||
/// <returns>The encoded CIP request bytes.</returns>
|
||||
/// <exception cref="ArgumentException">A structure write was requested without a handle.</exception>
|
||||
public static byte[] WriteTagFragmented(ReadOnlySpan<byte> path, ushort type, ushort? handle, ushort elements, uint offset, ReadOnlySpan<byte> data)
|
||||
{
|
||||
var w = Start(0x53, path).U16(type);
|
||||
@@ -27,6 +53,10 @@ public static class LogixServices
|
||||
return w.U16(elements).U32(offset).Bytes(data).ToArray();
|
||||
}
|
||||
|
||||
/// <summary>Parses a Read Tag reply.</summary>
|
||||
/// <param name="reply">The CIP reply.</param>
|
||||
/// <returns>The type code, the structure handle (for structure types, otherwise null) and the value data.</returns>
|
||||
/// <exception cref="AbTransportException">The reply carries a failure status.</exception>
|
||||
public static (ushort TypeCode, ushort? StructHandle, ReadOnlyMemory<byte> Data) ParseReadReply(CipReply reply)
|
||||
{
|
||||
if (reply.GeneralStatus is not (0x00 or 0x06))
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Logix;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Reads are issued as Read Tag Fragmented (0x52) starting at offset 0 — exactly what libplctag puts
|
||||
/// on the wire (golden captures, Task 4) — so the native and libplctag frames stay byte-comparable,
|
||||
/// on the wire (golden captures) — so the native and libplctag frames stay byte-comparable,
|
||||
/// and the continuation requests are the same service at a non-zero offset.
|
||||
/// </remarks>
|
||||
public sealed class LogixTagClient(RequestScheduler scheduler, ICipChannel channel)
|
||||
@@ -43,7 +43,8 @@ public sealed class LogixTagClient(RequestScheduler scheduler, ICipChannel chann
|
||||
/// <param name="ct">Cancels this read.</param>
|
||||
/// <param name="elementBytes">Upper bound on one element's data bytes (<see cref="LogixTypeCode.StringElementBytes"/>
|
||||
/// for a STRING), or 0 when unknown. The scheduler budgets the expected reply against the connection
|
||||
/// size when packing (#582); unknown sends the read alone.</param>
|
||||
/// size when packing; unknown sends the read alone.</param>
|
||||
/// <returns>The tag's type code, optional structure handle, and the reassembled data bytes.</returns>
|
||||
public async Task<(ushort TypeCode, ushort? StructHandle, byte[] Data)> ReadAsync(
|
||||
byte[] path, ushort elements, CancellationToken ct, int elementBytes = 0)
|
||||
{
|
||||
@@ -86,6 +87,10 @@ public sealed class LogixTagClient(RequestScheduler scheduler, ICipChannel chann
|
||||
/// <summary>Whole-reply bound for a Read Tag Fragmented at <paramref name="alreadyRead"/> bytes in:
|
||||
/// reply header, type (+ struct handle), then the remaining data. Unknown element size, or an
|
||||
/// unrepresentable total, is <see cref="RequestScheduler.UnknownReply"/>.</summary>
|
||||
/// <param name="elements">Element count being read.</param>
|
||||
/// <param name="elementBytes">Upper bound on one element's data bytes, or 0 when unknown.</param>
|
||||
/// <param name="alreadyRead">Data bytes already received in earlier fragments.</param>
|
||||
/// <returns>The expected reply size in bytes, or <see cref="RequestScheduler.UnknownReply"/>.</returns>
|
||||
internal static int ExpectedReadReplyBytes(ushort elements, int elementBytes, int alreadyRead)
|
||||
{
|
||||
if (elementBytes <= 0) return RequestScheduler.UnknownReply;
|
||||
@@ -105,6 +110,13 @@ public sealed class LogixTagClient(RequestScheduler scheduler, ICipChannel chann
|
||||
/// <remarks>A fragmented write is NOT atomic across fragments — the controller applies each
|
||||
/// fragment as it arrives, so a reader can observe a partly-written value. libplctag behaves the
|
||||
/// same way.</remarks>
|
||||
/// <param name="path">The encoded symbolic path.</param>
|
||||
/// <param name="type">The Logix type code.</param>
|
||||
/// <param name="handle">The structure handle for a structure type; otherwise <see langword="null"/>.</param>
|
||||
/// <param name="elements">Element count to write.</param>
|
||||
/// <param name="data">The encoded value bytes.</param>
|
||||
/// <param name="ct">Cancels this write.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
/// <exception cref="ArgumentException">For an atomic type, <paramref name="data"/> is not exactly
|
||||
/// <paramref name="elements"/> × the type's width.</exception>
|
||||
public async Task WriteAsync(byte[] path, ushort type, ushort? handle, ushort elements,
|
||||
|
||||
@@ -9,6 +9,8 @@ public static class LogixTypeCode
|
||||
public const int StringElementBytes = 88;
|
||||
|
||||
/// <summary>Byte width of an atomic type, or -1 when the code is not atomic (e.g. <see cref="Struct"/>).</summary>
|
||||
/// <param name="code">The Logix type code.</param>
|
||||
/// <returns>The byte width, or -1 for a non-atomic code.</returns>
|
||||
public static int ByteWidth(ushort code) => code switch
|
||||
{
|
||||
Bool or SInt or USInt => 1,
|
||||
|
||||
@@ -8,6 +8,9 @@ public static class SymbolObjectServices
|
||||
{
|
||||
/// <summary>Request attributes: 2 = type, 7 = element length, 8 = array dims, 1 = name. Identical
|
||||
/// to what libplctag sends (<c>logix-tags-listing.hex</c>).</summary>
|
||||
/// <param name="programName">Program scope to list, or <see langword="null"/> for the controller scope.</param>
|
||||
/// <param name="startInstance">First instance id to return.</param>
|
||||
/// <returns>The encoded CIP request bytes.</returns>
|
||||
public static byte[] GetInstanceAttributeList(string? programName, uint startInstance)
|
||||
{
|
||||
var path = new CipWriter();
|
||||
@@ -29,6 +32,8 @@ public static class SymbolObjectServices
|
||||
/// <c>MaxInstance</c> is the HIGHEST instance id in the page, not the last entry's: the next start is
|
||||
/// MaxInstance + 1, which does not rely on the controller listing a page in ascending order.
|
||||
/// <c>More</c> is true on general status 0x06 (partial transfer).</summary>
|
||||
/// <param name="reply">The CIP reply to parse.</param>
|
||||
/// <returns>The raw entries, the highest instance id in the page, and whether more pages remain.</returns>
|
||||
public static (byte[] Entries, uint MaxInstance, bool More) ParseListingReply(CipReply reply)
|
||||
{
|
||||
if (reply.GeneralStatus is not (0x00 or 0x06))
|
||||
|
||||
@@ -9,10 +9,15 @@ public static class TemplateObjectServices
|
||||
{
|
||||
/// <summary>Get Attribute List for attributes 4 (definition words), 5 (structure bytes),
|
||||
/// 2 (member count) and 1 (handle).</summary>
|
||||
/// <param name="templateId">The template instance id.</param>
|
||||
/// <returns>The encoded CIP request.</returns>
|
||||
public static byte[] GetAttributes(ushort templateId) =>
|
||||
new CipWriter().U8(0x03).U8(3).Bytes([0x20, 0x6C, 0x25, 0x00]).U16(templateId)
|
||||
.U16(4).U16(4).U16(5).U16(2).U16(1).ToArray();
|
||||
|
||||
/// <summary>Parses a Get Attribute List reply into <see cref="TemplateAttributes"/>.</summary>
|
||||
/// <param name="reply">The CIP reply to parse.</param>
|
||||
/// <returns>The template's definition words, structure bytes, member count and handle.</returns>
|
||||
public static TemplateAttributes ParseAttributes(CipReply reply)
|
||||
{
|
||||
if (reply.GeneralStatus != 0)
|
||||
@@ -38,14 +43,24 @@ public static class TemplateObjectServices
|
||||
}
|
||||
|
||||
/// <summary>Read Template: <c>4C 03 20 6C 25 00 id offset bytes</c>.</summary>
|
||||
/// <param name="templateId">The template instance id.</param>
|
||||
/// <param name="offset">Byte offset into the template definition.</param>
|
||||
/// <param name="bytes">Number of bytes to request.</param>
|
||||
/// <returns>The encoded CIP request.</returns>
|
||||
public static byte[] ReadTemplate(ushort templateId, uint offset, ushort bytes) =>
|
||||
new CipWriter().U8(0x4C).U8(3).Bytes([0x20, 0x6C, 0x25, 0x00]).U16(templateId).U32(offset).U16(bytes).ToArray();
|
||||
|
||||
/// <summary>More ⇔ general status 0x06.</summary>
|
||||
/// <param name="reply">The Read Template reply.</param>
|
||||
/// <returns><see langword="true"/> when more template bytes remain to be read.</returns>
|
||||
public static bool ReadHasMore(CipReply reply) => reply.GeneralStatus == 0x06;
|
||||
|
||||
/// <summary>The buffer <c>CipTemplateObjectDecoder</c> expects: <c>u16 id, u32 defWords,
|
||||
/// u32 structBytes, u16 members, u16 handle</c> then the raw template bytes.</summary>
|
||||
/// <param name="templateId">The template instance id.</param>
|
||||
/// <param name="attrs">The template attributes.</param>
|
||||
/// <param name="rawTemplate">The raw template definition bytes.</param>
|
||||
/// <returns>The assembled decoder input buffer.</returns>
|
||||
public static byte[] BuildDecoderBuffer(ushort templateId, TemplateAttributes attrs, ReadOnlySpan<byte> rawTemplate) =>
|
||||
new CipWriter().U16(templateId).U32(attrs.DefinitionWords).U32(attrs.StructureBytes)
|
||||
.U16(attrs.Members).U16(attrs.Handle).Bytes(rawTemplate).ToArray();
|
||||
|
||||
@@ -19,9 +19,15 @@ public sealed record PcccAddress(string FileLetter, int? FileNumber, int Element
|
||||
?? throw new AbTransportException(AbStatus.Unsupported, $"File type '{FileLetter}' needs an explicit file number.");
|
||||
|
||||
/// <summary>The word offset to read within the element, and the bit within that word if any.</summary>
|
||||
/// <returns>The word offset and the optional bit index.</returns>
|
||||
public (int Word, int? Bit) ResolveSubElement() => (SubElement ?? 0, Bit);
|
||||
|
||||
/// <summary>Builds an address from a sub-element mnemonic (PRE, ACC, LEN, POS, or a status bit such as DN).</summary>
|
||||
/// <param name="letter">The file letter (T, C or R).</param>
|
||||
/// <param name="fileNumber">The explicit file number, or null for the implicit one.</param>
|
||||
/// <param name="element">The element number.</param>
|
||||
/// <param name="subName">The sub-element mnemonic.</param>
|
||||
/// <returns>The resolved address.</returns>
|
||||
public static PcccAddress WithSubElementName(string letter, int? fileNumber, int element, string subName)
|
||||
{
|
||||
var l = letter.ToUpperInvariant();
|
||||
@@ -57,6 +63,8 @@ public sealed record PcccAddress(string FileLetter, int? FileNumber, int Element
|
||||
}
|
||||
|
||||
/// <summary>Parses <c>N7:0</c>, <c>B3:1/4</c>, <c>T4:0.ACC</c>, <c>I:0/1</c>, <c>ST9:2</c>; null when malformed.</summary>
|
||||
/// <param name="text">The address text.</param>
|
||||
/// <returns>The parsed address, or null when the text is malformed.</returns>
|
||||
public static PcccAddress? TryParse(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text)) return null;
|
||||
|
||||
@@ -11,6 +11,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Pccc;
|
||||
public static class PcccFileType
|
||||
{
|
||||
/// <summary>Returns the file-type code for a file letter (case-insensitive), or null when unknown.</summary>
|
||||
/// <param name="fileLetter">The data-table file letter, such as <c>N</c> or <c>F</c>.</param>
|
||||
/// <returns>The PCCC file-type code, or <see langword="null"/> when unknown.</returns>
|
||||
public static byte? CodeFor(string fileLetter) => fileLetter.ToUpperInvariant() switch
|
||||
{
|
||||
"O" => 0x8B,
|
||||
@@ -29,6 +31,8 @@ public static class PcccFileType
|
||||
};
|
||||
|
||||
/// <summary>The implicit file number for the single-letter system files (O=0, I=1, S=2), else null.</summary>
|
||||
/// <param name="fileLetter">The data-table file letter.</param>
|
||||
/// <returns>The implicit file number, or <see langword="null"/> when the file needs an explicit number.</returns>
|
||||
public static int? ImplicitFileNumber(string fileLetter) => fileLetter.ToUpperInvariant() switch
|
||||
{
|
||||
"O" => 0,
|
||||
|
||||
@@ -22,13 +22,17 @@ public static class PcccServices
|
||||
/// PCCC request with this fixed requestor id (<c>PCCC_RESP_PREFIX</c> in its <c>pccc.c</c>) instead of
|
||||
/// echoing the request's, while real controllers echo whatever was sent. Sending libplctag's identity is
|
||||
/// the only choice that lets the echo check in <see cref="ParseReply"/> pass against both (found by
|
||||
/// Task 17 slc500: with the 0x1337 originator identity every native PCCC reply was BadReply).</remarks>
|
||||
/// the slc500 simulator: with the 0x1337 originator identity every native PCCC reply was BadReply).</remarks>
|
||||
public const ushort RequestorVendor = 0xF33D;
|
||||
|
||||
/// <summary>Requestor serial number paired with <see cref="RequestorVendor"/> (libplctag's, from the captures).</summary>
|
||||
public const uint RequestorSerial = 0x21504345;
|
||||
|
||||
/// <summary>Wraps a PCCC command in Execute PCCC: <c>4B 02 20 67 24 01</c> + requestor id (7, vendor, serial) + PCCC.</summary>
|
||||
/// <param name="requestorVendor">The requestor vendor id.</param>
|
||||
/// <param name="requestorSerial">The requestor serial number.</param>
|
||||
/// <param name="pccc">The PCCC command bytes to wrap.</param>
|
||||
/// <returns>The encoded Execute PCCC CIP request.</returns>
|
||||
public static byte[] ExecutePccc(ushort requestorVendor, uint requestorSerial, ReadOnlySpan<byte> pccc) =>
|
||||
new CipWriter().U8(0x4B).U8(2).U8(0x20).U8(0x67).U8(0x24).U8(0x01)
|
||||
.U8(7).U16(requestorVendor).U32(requestorSerial).Bytes(pccc).ToArray();
|
||||
@@ -55,6 +59,10 @@ public static class PcccServices
|
||||
}
|
||||
|
||||
/// <summary>Protected typed logical read with 3 address fields (0x0F/0xA2).</summary>
|
||||
/// <param name="tns">The transaction number.</param>
|
||||
/// <param name="addr">The data-table address to read.</param>
|
||||
/// <param name="byteCount">Number of bytes to read.</param>
|
||||
/// <returns>The PCCC command bytes.</returns>
|
||||
public static byte[] SlcTypedRead(ushort tns, PcccAddress addr, int byteCount)
|
||||
{
|
||||
CheckCount(byteCount);
|
||||
@@ -64,7 +72,11 @@ public static class PcccServices
|
||||
}
|
||||
|
||||
/// <summary>Protected typed logical write with 3 address fields (0x0F/0xAA). SLC/MicroLogix/LogixPccc
|
||||
/// only: it must not be used for PLC-5 (the family guard lives in the AbLegacy native runtime, Task 14).</summary>
|
||||
/// only: it must not be used for PLC-5 (the family guard lives in the AbLegacy native runtime).</summary>
|
||||
/// <param name="tns">The transaction number.</param>
|
||||
/// <param name="addr">The data-table address to write.</param>
|
||||
/// <param name="data">The bytes to write.</param>
|
||||
/// <returns>The PCCC command bytes.</returns>
|
||||
public static byte[] SlcTypedWrite(ushort tns, PcccAddress addr, ReadOnlySpan<byte> data)
|
||||
{
|
||||
CheckCount(data.Length);
|
||||
@@ -77,6 +89,11 @@ public static class PcccServices
|
||||
/// <c>offset u16 (0), totalTrans u16, levelMask u8 (0x06 = file+element), file*, element*, byteCount u8</c>.
|
||||
/// <c>totalTrans</c> counts 16-bit WORDS (DF1 word range read; the capture shows 1 for one 2-byte N element).
|
||||
/// Only N-file reads are capture-verified, other PLC-5 file types are unverified; sub-element addressing is not and throws Unsupported.</summary>
|
||||
/// <param name="tns">The transaction number.</param>
|
||||
/// <param name="addr">The data-table address to read.</param>
|
||||
/// <param name="totalTrans">Total transaction size in 16-bit words.</param>
|
||||
/// <param name="byteCount">Number of bytes to read.</param>
|
||||
/// <returns>The PCCC command bytes.</returns>
|
||||
public static byte[] Plc5RangeRead(ushort tns, PcccAddress addr, ushort totalTrans, int byteCount)
|
||||
{
|
||||
CheckCount(byteCount);
|
||||
@@ -92,6 +109,7 @@ public static class PcccServices
|
||||
/// <param name="reply">The parsed CIP reply.</param>
|
||||
/// <param name="expectedTns">The request's TNS; checked before STS so a stale error reply is BadReply.</param>
|
||||
/// <param name="expectedRequestorId">Optional full echoed requestor id (length byte included) to compare.</param>
|
||||
/// <returns>The PCCC data bytes following the reply header.</returns>
|
||||
public static ReadOnlyMemory<byte> ParseReply(CipReply reply, ushort expectedTns, byte[]? expectedRequestorId = null)
|
||||
{
|
||||
if (reply.GeneralStatus != 0)
|
||||
|
||||
@@ -5,7 +5,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
|
||||
public ref struct CipReader(ReadOnlySpan<byte> data)
|
||||
{
|
||||
private readonly ReadOnlySpan<byte> _data = data;
|
||||
/// <summary>Gets the current read offset into the buffer.</summary>
|
||||
public int Position { get; private set; }
|
||||
/// <summary>Gets the number of bytes left to read.</summary>
|
||||
public readonly int Remaining => _data.Length - Position;
|
||||
|
||||
private ReadOnlySpan<byte> Take(int n)
|
||||
@@ -17,10 +19,23 @@ public ref struct CipReader(ReadOnlySpan<byte> data)
|
||||
return s;
|
||||
}
|
||||
|
||||
/// <summary>Reads an unsigned 8-bit value.</summary>
|
||||
/// <returns>The value read.</returns>
|
||||
public byte U8() => Take(1)[0];
|
||||
/// <summary>Reads a little-endian unsigned 16-bit value.</summary>
|
||||
/// <returns>The value read.</returns>
|
||||
public ushort U16() => BinaryPrimitives.ReadUInt16LittleEndian(Take(2));
|
||||
/// <summary>Reads a little-endian unsigned 32-bit value.</summary>
|
||||
/// <returns>The value read.</returns>
|
||||
public uint U32() => BinaryPrimitives.ReadUInt32LittleEndian(Take(4));
|
||||
/// <summary>Reads a little-endian unsigned 64-bit value.</summary>
|
||||
/// <returns>The value read.</returns>
|
||||
public ulong U64() => BinaryPrimitives.ReadUInt64LittleEndian(Take(8));
|
||||
/// <summary>Reads a fixed number of bytes.</summary>
|
||||
/// <param name="n">The number of bytes to read.</param>
|
||||
/// <returns>A span over the bytes read.</returns>
|
||||
public ReadOnlySpan<byte> Bytes(int n) => Take(n);
|
||||
/// <summary>Reads all remaining bytes.</summary>
|
||||
/// <returns>A span over the remaining bytes.</returns>
|
||||
public ReadOnlySpan<byte> Rest() => Take(Remaining);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Wire;
|
||||
public sealed class CipWriter
|
||||
{
|
||||
private byte[] _buf = new byte[256];
|
||||
/// <summary>Gets the number of bytes written so far.</summary>
|
||||
public int Length { get; private set; }
|
||||
|
||||
private Span<byte> Take(int n)
|
||||
@@ -15,14 +16,33 @@ public sealed class CipWriter
|
||||
return s;
|
||||
}
|
||||
|
||||
/// <summary>Appends an unsigned 8-bit value.</summary>
|
||||
/// <param name="v">The value to write.</param>
|
||||
/// <returns>This writer, for chaining.</returns>
|
||||
public CipWriter U8(byte v) { Take(1)[0] = v; return this; }
|
||||
/// <summary>Appends a little-endian unsigned 16-bit value.</summary>
|
||||
/// <param name="v">The value to write.</param>
|
||||
/// <returns>This writer, for chaining.</returns>
|
||||
public CipWriter U16(ushort v) { BinaryPrimitives.WriteUInt16LittleEndian(Take(2), v); return this; }
|
||||
/// <summary>Appends a little-endian unsigned 32-bit value.</summary>
|
||||
/// <param name="v">The value to write.</param>
|
||||
/// <returns>This writer, for chaining.</returns>
|
||||
public CipWriter U32(uint v) { BinaryPrimitives.WriteUInt32LittleEndian(Take(4), v); return this; }
|
||||
/// <summary>Appends a little-endian unsigned 64-bit value.</summary>
|
||||
/// <param name="v">The value to write.</param>
|
||||
/// <returns>This writer, for chaining.</returns>
|
||||
public CipWriter U64(ulong v) { BinaryPrimitives.WriteUInt64LittleEndian(Take(8), v); return this; }
|
||||
/// <summary>Appends raw bytes.</summary>
|
||||
/// <param name="v">The bytes to write.</param>
|
||||
/// <returns>This writer, for chaining.</returns>
|
||||
public CipWriter Bytes(ReadOnlySpan<byte> v) { v.CopyTo(Take(v.Length)); return this; }
|
||||
/// <summary>Appends a zero byte if the current length is odd.</summary>
|
||||
/// <returns>This writer, for chaining.</returns>
|
||||
public CipWriter PadToEven() { if ((Length & 1) == 1) U8(0); return this; }
|
||||
|
||||
/// <summary>Overwrite a u16 already written at <paramref name="offset"/> (length back-patching).</summary>
|
||||
/// <param name="offset">The offset of the first byte of the u16 to overwrite.</param>
|
||||
/// <param name="v">The replacement value.</param>
|
||||
/// <exception cref="ArgumentOutOfRangeException">The two bytes at <paramref name="offset"/> are not already
|
||||
/// written (an offset in the spare buffer capacity would otherwise be patched silently and lost).</exception>
|
||||
public void PatchU16(int offset, ushort v)
|
||||
@@ -32,5 +52,7 @@ public sealed class CipWriter
|
||||
BinaryPrimitives.WriteUInt16LittleEndian(_buf.AsSpan(offset, 2), v);
|
||||
}
|
||||
|
||||
/// <summary>Copies the written bytes to a new array.</summary>
|
||||
/// <returns>The bytes written so far.</returns>
|
||||
public byte[] ToArray() => _buf.AsSpan(0, Length).ToArray();
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ public sealed class AbCipDriverOptions
|
||||
/// <para>Changing this takes effect when the driver instance is RECREATED, not on
|
||||
/// <c>ReinitializeAsync</c>: the factories are chosen once in the constructor and held across
|
||||
/// Shutdown+Initialize. A deploy that changes the driver config stops and respawns the instance
|
||||
/// (<c>DriverSpawnPlanner</c>, #516), so an operator edit does recreate it.</para>
|
||||
/// (<c>DriverSpawnPlanner</c>), so an operator edit does recreate it.</para>
|
||||
/// </summary>
|
||||
/// <remarks>Null is never written: an absent key IS the "use the default" encoding, so a round-trip
|
||||
/// through any serializer (the AdminUI form included) keeps a defaulted driver defaulted rather than
|
||||
@@ -111,7 +111,7 @@ public sealed class AbCipDriverOptions
|
||||
/// <summary>
|
||||
/// Upper bound on how many planned read work items (whole-UDT groups + per-tag fallbacks)
|
||||
/// one <c>ReadAsync</c> batch may have in flight against the controller at once. Bounds
|
||||
/// concurrency ACROSS tags only — the per-tag runtime lock (05/STAB-4) still serialises
|
||||
/// concurrency ACROSS tags only — the per-tag runtime lock still serialises
|
||||
/// Read → GetStatus → Decode against any one cached libplctag handle, so the same tag
|
||||
/// requested twice in a batch never overlaps itself. Raising it lets libplctag overlap
|
||||
/// device round trips (the whole point: a strictly sequential batch pays one full round
|
||||
|
||||
@@ -70,7 +70,7 @@ public static class AbCipTagDefinitionFactory
|
||||
var tagPath = tagPathEl.GetString();
|
||||
if (string.IsNullOrWhiteSpace(tagPath)) return false;
|
||||
|
||||
// Strict enum read (R2-11 Phase C): a typo'd dataType rejects the tag (→ BadNodeIdUnknown)
|
||||
// Strict enum read: a typo'd dataType rejects the tag (→ BadNodeIdUnknown)
|
||||
// instead of silently defaulting to a wrong-width Good.
|
||||
if (!TagConfigJson.TryReadEnumStrict(root, "dataType", AbCipDataType.DInt, out var dataType)) return false;
|
||||
// Review I-1 — an equipment tag is an ARRAY ⟺ isArray:true AND arrayLength >= 1. A 1-element
|
||||
@@ -195,16 +195,16 @@ public static class AbCipTagDefinitionFactory
|
||||
private const string InspectionRawPath = "(inspect)";
|
||||
|
||||
/// <summary>
|
||||
/// Deploy-time inspection (05/CONV-2): warns for a blob <see cref="FromTagConfig"/> would
|
||||
/// Deploy-time inspection warns for a blob <see cref="FromTagConfig"/> would
|
||||
/// <b>reject</b> — a structurally unparseable or non-object document, an absent / non-string /
|
||||
/// blank <c>tagPath</c>, a present-but-invalid <c>dataType</c> (the runtime enum read is STRICT
|
||||
/// since R2-11 Phase C, so a typo rejects the tag), or a malformed declared UDT member (see
|
||||
/// so a typo rejects the tag), or a malformed declared UDT member (see
|
||||
/// <see cref="DescribeInvalidMember(JsonElement)"/>). Such a tag is skipped by the driver and reads
|
||||
/// <c>BadNodeIdUnknown</c> forever. Empty when the mapper accepts the blob, when it is blank, or
|
||||
/// when it is not an equipment-tag object. Never throws.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>The verdict comes from <see cref="FromTagConfig"/>, not from a mirror</b> (Gitea #551) — see
|
||||
/// <b>The verdict comes from <see cref="FromTagConfig"/>, not from a mirror</b> — see
|
||||
/// <c>ModbusTagDefinitionFactory.Inspect</c>'s remarks for the full argument.
|
||||
/// </remarks>
|
||||
/// <param name="reference">The equipment tag's TagConfig JSON.</param>
|
||||
@@ -212,7 +212,7 @@ public static class AbCipTagDefinitionFactory
|
||||
public static IReadOnlyList<string> Inspect(string reference)
|
||||
{
|
||||
var warnings = new List<string>();
|
||||
// Gitea #556 item 1: the framing check TRIMS FIRST. FromTagConfig hands the blob straight to
|
||||
// The framing check TRIMS FIRST. FromTagConfig hands the blob straight to
|
||||
// JsonDocument.Parse, which tolerates leading whitespace — so an untrimmed check let " {…}" be
|
||||
// read (and possibly REFUSED) by the driver while drawing zero deploy warnings. A reference that
|
||||
// still does not start with '{' after trimming is not an equipment-tag TagConfig at all and keeps
|
||||
@@ -286,7 +286,7 @@ public static class AbCipTagDefinitionFactory
|
||||
/// WHOLE tag for one bad member), but no authoring surface checked them: the typed editor emits
|
||||
/// only <c>tagPath</c>/<c>dataType</c>/<c>writable</c> and <c>members</c> rides the model's
|
||||
/// preserved key bag untouched, so a hand-authored / CSV / API blob with a typo'd member
|
||||
/// deployed clean and died at the driver — Gitea #551 item 3.
|
||||
/// deployed clean and died at the driver.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A <c>members</c> value that is absent or not a JSON array draws nothing: the mapper reads it
|
||||
|
||||
@@ -72,7 +72,7 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
|
||||
// prior subscriptions before starting the new one is semantically faithful. Snapshotting +
|
||||
// clearing under the same lock DisposeAsync uses guarantees the stale subs can never be
|
||||
// double-owned (and thus never double-disposed) by a racing dispose.
|
||||
// Gitea #641 review — forget only the refs that LEFT the set. A survivor keeps its last-seen InFaulted, so
|
||||
// Forget only the refs that LEFT the set. A survivor keeps its last-seen InFaulted, so
|
||||
// a re-subscribe (every reconnect, every alarm-set change) does not re-RAISE a still-faulted alarm: the
|
||||
// server carries the condition across a rebuild, and a Raise would un-acknowledge it. A ref that left and
|
||||
// comes back was materialised fresh, so it starts from "not faulted" and is raised again.
|
||||
@@ -222,7 +222,7 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
|
||||
var wasFaulted = _driver.AlarmFaultRecord.Exchange(nodeId, nowFaulted);
|
||||
|
||||
// ConditionId is the subscribed reference itself — the alarm tag's RawPath, the key the host routes
|
||||
// native transitions on — and Kind tells the host's projector what changed (Gitea #641). This used
|
||||
// native transitions on — and Kind tells the host's projector what changed. This used
|
||||
// to send "{ref}#active" with no Kind, so the host dropped every ALMD transition as unrouted (and an
|
||||
// Unspecified kind would have projected no state change anyway).
|
||||
if (!wasFaulted && nowFaulted)
|
||||
@@ -318,7 +318,7 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
|
||||
/// <item>none->active: Raise, or the atomic ActiveAcknowledged when every declared limit is acked.</item>
|
||||
/// <item>active->none: Clear.</item>
|
||||
/// <item>a limit NEWLY entering: Retrigger (a new unacknowledged occurrence) — or ActiveAcknowledged when
|
||||
/// nothing is pending ack (AckRequired false), never Retrigger-then-Acknowledge (#656).</item>
|
||||
/// nothing is pending ack (AckRequired false), never Retrigger-then-Acknowledge.</item>
|
||||
/// <item>no new limit, every declared limit now acked while the condition was unacked: Acknowledge.</item>
|
||||
/// <item>no new limit, the condition was fully acked and a declared <c>*Acked</c> read (Good) is now false:
|
||||
/// Retrigger — an occurrence the poll missed.</item>
|
||||
@@ -455,13 +455,18 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
|
||||
private readonly List<int?> _ackIdx = [];
|
||||
private readonly List<int?> _sevIdx = [];
|
||||
|
||||
/// <summary>The limits the tag declares, in read order.</summary>
|
||||
public List<AbCipAlmaLimit> Limits { get; } = [];
|
||||
|
||||
/// <summary>The total number of member references the plan polls.</summary>
|
||||
public int RefCount { get; private set; }
|
||||
|
||||
/// <summary>True when the tag declares at least one <c>*Acked</c> member.</summary>
|
||||
public bool HasAcked => _acked.Any(a => a is not null);
|
||||
|
||||
/// <summary>Builds the poll plan from the members the ALMA tag declares.</summary>
|
||||
/// <param name="def">The ALMA tag definition.</param>
|
||||
/// <returns>The plan covering every declared limit.</returns>
|
||||
public static AlmaPlan From(AbCipTagDefinition def)
|
||||
{
|
||||
var names = def.Members!.Select(m => m.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -482,13 +487,25 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
|
||||
return plan;
|
||||
}
|
||||
|
||||
/// <summary>Enumerates the member references to poll for an alarm node.</summary>
|
||||
/// <param name="nodeId">The alarm node's base reference.</param>
|
||||
/// <returns>The fully qualified member references.</returns>
|
||||
public IEnumerable<string> Refs(string nodeId) =>
|
||||
_inAlarm.Concat(_acked.Where(a => a is not null).Select(a => a!))
|
||||
.Concat(_severity.Where(v => v is not null).Select(v => v!))
|
||||
.Select(m => $"{nodeId}.{m}");
|
||||
|
||||
/// <summary>Index of the <c>*InAlarm</c> member for limit slot <paramref name="k"/> in the read results.</summary>
|
||||
/// <param name="k">The limit slot.</param>
|
||||
/// <returns>The result index.</returns>
|
||||
public int InAlarmIndex(int k) => k;
|
||||
/// <summary>Index of the <c>*Acked</c> member for limit slot <paramref name="k"/>, or null when not declared.</summary>
|
||||
/// <param name="k">The limit slot.</param>
|
||||
/// <returns>The result index, or <see langword="null"/>.</returns>
|
||||
public int? AckedIndex(int k) => _ackIdx[k];
|
||||
/// <summary>Index of the <c>*Severity</c> member for limit slot <paramref name="k"/>, or null when not declared.</summary>
|
||||
/// <param name="k">The limit slot.</param>
|
||||
/// <returns>The result index, or <see langword="null"/>.</returns>
|
||||
public int? SeverityIndex(int k) => _sevIdx[k];
|
||||
}
|
||||
|
||||
@@ -521,7 +538,7 @@ internal sealed class AbCipAlarmProjection : IAsyncDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #641 review — the last <c>InAlarm</c> the ALMD projection told the server, per alarm RawPath. Owned by
|
||||
/// The last <c>InAlarm</c> the ALMD projection told the server, per alarm RawPath. Owned by
|
||||
/// the <see cref="AbCipDriver"/> rather than a subscription or a projection, so it survives a re-subscribe and a
|
||||
/// re-initialise (both of which happen on every reconnect): the server's condition nodes outlive them, and a
|
||||
/// re-raise there would un-acknowledge. Thread-safe — a retiring poll loop may overlap the new one for a tick.
|
||||
@@ -570,11 +587,22 @@ internal static class AbCipAlmaLimitExtensions
|
||||
{
|
||||
private static string Prefix(AbCipAlmaLimit l) => l.ToString();
|
||||
|
||||
/// <summary>The <c>*InAlarm</c> member name for the limit.</summary>
|
||||
/// <param name="l">The limit.</param>
|
||||
/// <returns>The member name.</returns>
|
||||
public static string InAlarmMember(this AbCipAlmaLimit l) => Prefix(l) + "InAlarm";
|
||||
/// <summary>The <c>*Acked</c> member name for the limit.</summary>
|
||||
/// <param name="l">The limit.</param>
|
||||
/// <returns>The member name.</returns>
|
||||
public static string AckedMember(this AbCipAlmaLimit l) => Prefix(l) + "Acked";
|
||||
/// <summary>The <c>*Severity</c> member name for the limit.</summary>
|
||||
/// <param name="l">The limit.</param>
|
||||
/// <returns>The member name.</returns>
|
||||
public static string SeverityMember(this AbCipAlmaLimit l) => Prefix(l) + "Severity";
|
||||
|
||||
/// <summary>Operator-facing name used in the alarm message.</summary>
|
||||
/// <param name="l">The limit.</param>
|
||||
/// <returns>The display label.</returns>
|
||||
public static string Label(this AbCipAlmaLimit l) => l switch
|
||||
{
|
||||
AbCipAlmaLimit.ROCPos => "ROC+",
|
||||
@@ -596,18 +624,26 @@ internal sealed class AbCipAlarmAnalogRecord
|
||||
private readonly Lock _lock = new();
|
||||
|
||||
/// <summary>The state last told for <paramref name="nodeId"/> (all-zero when none).</summary>
|
||||
/// <param name="nodeId">The alarm node's base reference.</param>
|
||||
/// <returns>The last-told state.</returns>
|
||||
public State Get(string nodeId)
|
||||
{
|
||||
lock (_lock) return _last.GetValueOrDefault(nodeId, new State(0, false, -1, 0));
|
||||
}
|
||||
|
||||
/// <summary>Records the state just told.</summary>
|
||||
/// <param name="nodeId">The alarm node's base reference.</param>
|
||||
/// <param name="mask">Bitmask of active limits.</param>
|
||||
/// <param name="acked">Whether every active limit is acknowledged.</param>
|
||||
/// <param name="top">Index of the highest-priority active limit, or -1.</param>
|
||||
/// <param name="topSeverity">Severity of the top active limit.</param>
|
||||
public void Set(string nodeId, int mask, bool acked, int top, int topSeverity)
|
||||
{
|
||||
lock (_lock) _last[nodeId] = new State(mask, acked, top, topSeverity);
|
||||
}
|
||||
|
||||
/// <summary>Forgets every ref not in <paramref name="nodeIds"/>.</summary>
|
||||
/// <param name="nodeIds">The newly subscribed refs.</param>
|
||||
public void RetainOnly(IReadOnlyList<string> nodeIds)
|
||||
{
|
||||
var keep = new HashSet<string>(nodeIds, StringComparer.Ordinal);
|
||||
|
||||
@@ -29,7 +29,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, IBatchSubscribable,
|
||||
IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, ISkippedTagReporter, IDisposable, IAsyncDisposable
|
||||
{
|
||||
/// <summary>Authored tags this driver could not build into its poll table (Gitea #541).</summary>
|
||||
/// <summary>Authored tags this driver could not build into its poll table.</summary>
|
||||
private readonly SkippedTagLedger _skippedTags = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -44,7 +44,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// <summary>
|
||||
/// The batch template reader of the discovery run in progress on this async flow, if any. While set,
|
||||
/// <see cref="FetchUdtShapeAsync"/> reads through it so a browse that fetches many templates holds ONE
|
||||
/// device lease across them instead of opening a connection per template (Gitea #572).
|
||||
/// device lease across them instead of opening a connection per template.
|
||||
/// </summary>
|
||||
private readonly AsyncLocal<IAbCipTemplateReader?> _batchTemplateReader = new();
|
||||
private readonly AbCipTemplateCache _templateCache = new();
|
||||
@@ -119,7 +119,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
private readonly ILogger<AbCipDriver> _logger;
|
||||
private AbCipAlarmProjection _alarmProjection;
|
||||
|
||||
/// <summary>Gitea #641 review — the ALMD projection's last-told InAlarm per alarm ref; outlives every
|
||||
/// <summary>The ALMD projection's last-told InAlarm per alarm ref; outlives every
|
||||
/// projection and subscription (see <see cref="AbCipAlarmFaultRecord"/>).</summary>
|
||||
internal AbCipAlarmFaultRecord AlarmFaultRecord { get; } = new();
|
||||
|
||||
@@ -128,6 +128,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
internal AbCipAlarmAnalogRecord AlarmAnalogRecord { get; } = new();
|
||||
|
||||
/// <summary>Resolves an authored tag definition by RawPath (the alarm projection's ALMA/ALMD discriminator).</summary>
|
||||
/// <param name="rawPath">The tag RawPath.</param>
|
||||
/// <param name="definition">The authored definition when found.</param>
|
||||
/// <returns><see langword="true"/> when a definition exists for <paramref name="rawPath"/>.</returns>
|
||||
internal bool TryGetTagDefinition(string rawPath, out AbCipTagDefinition definition) =>
|
||||
_tagsByRawPath.TryGetValue(rawPath, out definition!);
|
||||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||||
@@ -226,7 +229,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
_logger.LogWarning(message, args);
|
||||
}
|
||||
|
||||
// Tags whose write-refusal Warning has already been logged (Gitea #611) — a client may retry forever.
|
||||
// Tags whose write-refusal Warning has already been logged — a client may retry forever.
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, byte> _writeRefusalWarned =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -237,7 +240,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
_logger.LogWarning(message, args);
|
||||
}
|
||||
|
||||
// Tags whose malformed-name Warning has already been logged (Gitea #570) — polled forever, warned once.
|
||||
// Tags whose malformed-name Warning has already been logged — polled forever, warned once.
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, byte> _configErrorWarned =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -255,11 +258,11 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
reference, device, ex.Message);
|
||||
}
|
||||
|
||||
/// <summary>Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide (05/STAB-8).</summary>
|
||||
/// <summary>Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide.</summary>
|
||||
private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// 05/STAB-9 — routes a poll-loop reader failure to the driver health surface: logs it and
|
||||
/// Routes a poll-loop reader failure to the driver health surface: logs it and
|
||||
/// degrades to <see cref="DriverState.Degraded"/> preserving <c>LastSuccessfulRead</c>.
|
||||
/// Never downgrades a <see cref="DriverState.Faulted"/> state (a stronger, config-level signal).
|
||||
/// </summary>
|
||||
@@ -360,7 +363,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// <inheritdoc />
|
||||
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
// #630: InitializeAsync is reachable on a live driver with no teardown first (the IDriver contract
|
||||
// InitializeAsync is reachable on a live driver with no teardown first (the IDriver contract
|
||||
// allows it, and a reconnect retry can overlap a running attempt). Release the previous device states' probe loops + handles before
|
||||
// the overwrite below. Deliberately NOT ShutdownAsync: that also drops _poll and the subscriptions.
|
||||
await ReleaseDeviceStatesAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -654,7 +657,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
await probeRuntime.ReadAsync(ct).ConfigureAwait(false);
|
||||
success = probeRuntime.GetStatus() == 0;
|
||||
if (success)
|
||||
backoffVerdict = true; // 05/STAB-8: device reachable — reset the data-path window
|
||||
backoffVerdict = true; // Device reachable — reset the data-path window
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
@@ -671,8 +674,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
try { probeRuntime?.Dispose(); } catch { }
|
||||
probeRuntime = null;
|
||||
state.ProbeInitialized = false;
|
||||
// 05/STAB-8: keep the data-path window open — unless the probe tag's own name is malformed
|
||||
// (Gitea #570): that never reached the device, so it is no evidence either way.
|
||||
// Keep the data-path window open — unless the probe tag's own name is malformed
|
||||
// — it never reached the device, so it is no evidence either way.
|
||||
backoffVerdict = ex is AbCipTagConfigurationException ? null : false;
|
||||
}
|
||||
|
||||
@@ -728,15 +731,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
// ---- IPerCallHostResolver ----
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a read/write/subscribe reference (a RawPath in v3) to the device host that keys its
|
||||
/// per-host resilience (bulkhead / circuit breaker). CONV-4: routes through the authored table so a
|
||||
/// tag resolves to its OWN device's host rather than the first-device fallback — else a broken
|
||||
/// device B would trip device A's breaker. The tag's device routing key is resolved to the device's
|
||||
/// actual host address (the resilience key), matching by DeviceName-or-host.
|
||||
/// </summary>
|
||||
/// <param name="fullReference">The tag RawPath reference.</param>
|
||||
/// <returns>The device host key.</returns>
|
||||
// Resolves a read/write/subscribe reference (a RawPath) to the device host that keys its per-host
|
||||
// resilience (bulkhead / circuit breaker). Routes through the authored table so a tag resolves to
|
||||
// its OWN device's host rather than the first-device fallback - else a broken device B would trip
|
||||
// device A's breaker. The tag's device routing key is resolved to the device's actual host address
|
||||
// (the resilience key), matching by DeviceName-or-host.
|
||||
/// <inheritdoc />
|
||||
public string ResolveHost(string fullReference)
|
||||
{
|
||||
if (_resolver.TryResolve(fullReference, out var def)
|
||||
@@ -772,7 +772,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
// Bounded-parallel window ACROSS work items (groups ∪ fallbacks). Each item still takes its
|
||||
// OWN per-tag runtime lock inside ReadGroupAsync / ReadSingleAsync, so one libplctag handle
|
||||
// is never touched concurrently (05/STAB-4) — the window only lets DIFFERENT tags' device
|
||||
// is never touched concurrently — the window only lets DIFFERENT tags' device
|
||||
// round trips overlap, which a strictly sequential await chain made impossible. Results are
|
||||
// index-addressed, so completion order is irrelevant. Per-item health verdicts are COLLECTED
|
||||
// and written once after the batch: an inline write to the shared _health field would race.
|
||||
@@ -835,6 +835,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
/// <summary>The item failed on the wire.</summary>
|
||||
/// <param name="message">The failure detail surfaced as <c>LastError</c>.</param>
|
||||
/// <returns>A degraded outcome carrying <paramref name="message"/>.</returns>
|
||||
public static ReadOutcome Degraded(string message) => new(DriverState.Degraded, message);
|
||||
}
|
||||
|
||||
@@ -908,7 +909,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
var runtime = await EnsureTagRuntimeAsync(device, def, ct).ConfigureAwait(false);
|
||||
|
||||
// Serialise Read → GetStatus → Decode against the shared runtime — the cached libplctag
|
||||
// Tag handle is not concurrency-safe and GetStatus returns the LAST op's status (05/STAB-4).
|
||||
// Tag handle is not concurrency-safe and GetStatus returns the LAST op's status.
|
||||
var opLock = device.GetRuntimeLock(def.Name);
|
||||
await opLock.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
@@ -985,13 +986,13 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
catch (AbTransportException ate) when (ate.Status == AbStatus.Unsupported)
|
||||
{
|
||||
// A deterministic local refusal — not a comms fault: BadNotSupported, keep the handle, no
|
||||
// health signal (Task 22 review r1).
|
||||
// health signal.
|
||||
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNotSupported, null, now);
|
||||
return ReadOutcome.None;
|
||||
}
|
||||
catch (AbCipTagConfigurationException ace)
|
||||
{
|
||||
// Gitea #570: this tag's name/path cannot become a request — nothing reached the device, so
|
||||
// This tag's name/path cannot become a request — nothing reached the device, so
|
||||
// no evict (nothing was cached), no health signal, no backoff; siblings keep reading.
|
||||
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadConfigurationError, null, now);
|
||||
WarnConfigurationErrorOnce(def.Name, reference, def.DeviceHostAddress, ace);
|
||||
@@ -1043,7 +1044,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
var runtime = await EnsureTagRuntimeAsync(device, parent, ct).ConfigureAwait(false);
|
||||
|
||||
// Serialise the whole-UDT Read → GetStatus → per-member DecodeValueAt against the shared
|
||||
// parent runtime — the members decode from that one handle's buffer (05/STAB-4).
|
||||
// parent runtime — the members decode from that one handle's buffer.
|
||||
var opLock = device.GetRuntimeLock(parent.Name);
|
||||
await opLock.WaitAsync(ct).ConfigureAwait(false);
|
||||
try
|
||||
@@ -1096,7 +1097,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
catch (AbCipTagConfigurationException ace)
|
||||
{
|
||||
// Gitea #570: the parent's name cannot become a request — per-group config error, device untouched.
|
||||
// The parent's name cannot become a request — per-group config error, device untouched.
|
||||
StampGroupStatus(group, results, now, AbCipStatusMapper.BadConfigurationError);
|
||||
WarnConfigurationErrorOnce(parent.Name, group.ParentName, parent.DeviceHostAddress, ace);
|
||||
return ReadOutcome.None;
|
||||
@@ -1156,7 +1157,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
continue;
|
||||
}
|
||||
|
||||
// Gitea #611 item 3 — whole-UDT writes are refused by DECISION, not deferred: a structure write
|
||||
// Whole-UDT writes are refused by DECISION, not deferred: a structure write
|
||||
// overwrites every member of the controller's instance, hidden BOOL hosts and padding included,
|
||||
// with whatever the client sent. Address the members instead (each is its own raw tag). Refused
|
||||
// before a runtime exists, so nothing is read or sent; a refusal is not a fault — no health change.
|
||||
@@ -1190,7 +1191,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Serialise Encode → Write → GetStatus against the shared runtime — the same cached
|
||||
// Tag handle may be in use by a concurrent ReadAsync or poll loop (05/STAB-4).
|
||||
// Tag handle may be in use by a concurrent ReadAsync or poll loop.
|
||||
var opLock = device.GetRuntimeLock(def.Name);
|
||||
await opLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
int status;
|
||||
@@ -1198,7 +1199,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
int size;
|
||||
try
|
||||
{
|
||||
// Gitea #611: the bytes the controller holds for this path must be what the authored type
|
||||
// The bytes the controller holds for this path must be what the authored type
|
||||
// encodes, BEFORE anything is sent — on BOTH transports. libplctag never checks: it encodes
|
||||
// into the buffer the last read sized and sends the whole buffer, so an INT authored over a
|
||||
// DINT patched two bytes and wrote four (stale high word), and a BOOL over a packed
|
||||
@@ -1261,7 +1262,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
{
|
||||
// A deterministic local refusal (a runtime that cannot encode this write without clobbering
|
||||
// something — e.g. a BOOL-array element as a single BOOL) — not a transport fault: keep the
|
||||
// handle and leave driver health alone (Gitea #611; same rule as the Unsupported refusal
|
||||
// handle and leave driver health alone (same rule as the Unsupported refusal
|
||||
// below). It used to latch Degraded, which a refusal the device never saw must not do.
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadNotSupported);
|
||||
WarnWriteRefusedOnce(def.Name,
|
||||
@@ -1296,7 +1297,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
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 (Task 22 review r1).
|
||||
// fault: BadNotSupported, keep the handle, leave health alone.
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadNotSupported);
|
||||
_logger.LogWarning(ate,
|
||||
"AbCip write refused (not supported) for tag {Tag} on device {Device}",
|
||||
@@ -1304,7 +1305,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
catch (AbCipTagConfigurationException ace)
|
||||
{
|
||||
// Gitea #570: a config error on this tag — no evict, no health signal, no backoff.
|
||||
// A config error on this tag — no evict, no health signal, no backoff.
|
||||
results[i] = new WriteResult(AbCipStatusMapper.BadConfigurationError);
|
||||
WarnConfigurationErrorOnce(def.Name, w.FullReference, def.DeviceHostAddress, ace);
|
||||
}
|
||||
@@ -1400,7 +1401,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when a per-device <see cref="ConnectionBackoff"/> window is still open (05/STAB-8)
|
||||
/// Thrown when a per-device <see cref="ConnectionBackoff"/> window is still open
|
||||
/// — a fail-fast that costs no wire traffic. Read/write map it to <c>BadCommunicationError</c>
|
||||
/// via their generic catch, exactly as a real Forward-Open failure.
|
||||
/// </summary>
|
||||
@@ -1434,7 +1435,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
// may have materialised this very handle) while this caller waited.
|
||||
if (device.Runtimes.TryGetValue(def.Name, out var raced)) return raced;
|
||||
|
||||
// 05/STAB-8: inside an open backoff window a data-path caller fails fast — no runtime
|
||||
// Inside an open backoff window a data-path caller fails fast — no runtime
|
||||
// create, no Forward-Open. The malformed-path config error above still surfaces (a permanent
|
||||
// fault must never be masked by the window); only the expensive create+init is throttled. The
|
||||
// probe loop uses its own runtime, and records under this same gate.
|
||||
@@ -1455,7 +1456,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
catch (AbTransportException ate) when (ate.Status == AbStatus.TypeMismatch)
|
||||
{
|
||||
// Gitea #570: the controller ANSWERED with a type refusal — the tag exists and the session
|
||||
// The controller ANSWERED with a type refusal — the tag exists and the session
|
||||
// works. Keep the handle (disposing it re-creates it, and for a sole lease holder
|
||||
// re-Forward-Opens, on every poll) and let the caller report BadTypeMismatch. The native
|
||||
// runtime completes its initialization instead of throwing this; the cache keeps any runtime
|
||||
@@ -1466,7 +1467,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
catch (AbCipTagConfigurationException)
|
||||
{
|
||||
// Gitea #570: the tag's own name cannot become a request — no I/O happened, so it says nothing
|
||||
// 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.
|
||||
runtime.Dispose();
|
||||
throw;
|
||||
@@ -1474,7 +1475,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
catch (Exception ex)
|
||||
{
|
||||
runtime.Dispose();
|
||||
// 05/STAB-8: a failed create opens the backoff window — but only when the device did
|
||||
// A failed create opens the backoff window — but only when the device did
|
||||
// not answer. "Tag not found" means the controller replied over a working session; it
|
||||
// is a config error on this one tag, and counting it as a failed Forward-Open throttled
|
||||
// every sibling tag on the device (seen live: one typo blacked out the whole device).
|
||||
@@ -1486,7 +1487,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
device.Backoff.RecordFailure(DateTime.UtcNow);
|
||||
throw;
|
||||
}
|
||||
device.Backoff.RecordSuccess(); // 05/STAB-8: Forward-Open succeeded — reset the window
|
||||
device.Backoff.RecordSuccess(); // Forward-Open succeeded — reset the window
|
||||
// The gate makes a cold create single-flight, but a concurrent EvictRuntime (a failed read
|
||||
// on this handle) can still land between the re-check and the add; only the first TryAdd
|
||||
// wins. Dispose the loser so it doesn't leak a native tag handle.
|
||||
@@ -1525,8 +1526,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// <inheritdoc />
|
||||
public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once;
|
||||
|
||||
/// <summary>Controller enumeration is real (CIP Symbol Object walk) — config-gated on
|
||||
/// EnableControllerBrowse, which the universal browser's PatchForBrowse guarantees at open time.</summary>
|
||||
// Controller enumeration is real (CIP Symbol Object walk) - config-gated on
|
||||
// EnableControllerBrowse, which the universal browser's PatchForBrowse guarantees at open time.
|
||||
/// <inheritdoc />
|
||||
public bool SupportsOnlineDiscovery => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -1671,6 +1673,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// <summary>Clears an <see cref="AsyncLocal{T}"/> when the scope that set it ends.</summary>
|
||||
private sealed class ClearOnDispose(AsyncLocal<IAbCipTemplateReader?> slot) : IDisposable
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void Dispose() => slot.Value = null;
|
||||
}
|
||||
|
||||
@@ -1712,7 +1715,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
type == AbCipDataType.Bool ? 1 : ExpectedScalarWidth(type);
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #611 — the refusal status for a scalar write whose authored type does not fit what the controller
|
||||
/// The refusal status for a scalar write whose authored type does not fit what the controller
|
||||
/// holds at that path (<paramref name="size"/> bytes, from the runtime's last read), or <c>null</c> to
|
||||
/// write. A BOOL over a wider value (a packed BOOL-array DWORD) is <c>BadNotSupported</c> — writing it
|
||||
/// would clobber the neighbouring bits, the same refusal the native runtime raises; any other width
|
||||
@@ -1926,7 +1929,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
public object ProbeLock { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Per-device connect-attempt throttle (05/STAB-8). After a failed runtime create
|
||||
/// Per-device connect-attempt throttle. After a failed runtime create
|
||||
/// (Forward-Open), data-path callers fail fast inside the backoff window instead of
|
||||
/// re-creating + re-initializing a runtime per tag per tick; the probe loop bypasses it
|
||||
/// (its own runtime) and drives failure/success so a recovered device resets the window.
|
||||
@@ -1936,12 +1939,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// <summary>
|
||||
/// Per-device connect gate. <see cref="ConnectionBackoff"/> is documented as
|
||||
/// caller-serialized — it mutates plain fields — so EVERY touch of <see cref="Backoff"/>
|
||||
/// plus the connect attempt it guards runs under this semaphore. Task 14's bounded-parallel
|
||||
/// plus the connect attempt it guards runs under this semaphore. The bounded-parallel
|
||||
/// read window otherwise let up to <c>MaxConcurrentReads</c> work items run the
|
||||
/// ShouldAttempt → create+Initialize → RecordFailure/RecordSuccess sequence at once for
|
||||
/// DIFFERENT cold tags on the SAME device: every one passed <c>ShouldAttempt</c> before any
|
||||
/// failure was recorded (N concurrent Forward-Opens at a down device) and the unsynchronized
|
||||
/// <c>_consecutiveFailures++</c> lost increments, weakening the 05/STAB-8 window into a
|
||||
/// <c>_consecutiveFailures++</c> lost increments, weakening the backoff window into a
|
||||
/// reconnect storm. Cold-tag creation is therefore single-flight per device; an
|
||||
/// already-created runtime bypasses the gate entirely, so the steady-state read path keeps
|
||||
/// the full window.
|
||||
@@ -2004,7 +2007,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Per-runtime operation lock (05/STAB-4). A libplctag <c>Tag</c> handle is not safe for
|
||||
/// Per-runtime operation lock. A libplctag <c>Tag</c> handle is not safe for
|
||||
/// concurrent Read/GetStatus/DecodeValue (or Encode/Write/GetStatus) — the server read
|
||||
/// path, every poll-group loop, and the alarm-projection loop all call
|
||||
/// <see cref="AbCipDriver.ReadAsync"/> / <see cref="AbCipDriver.WriteAsync"/> against the
|
||||
|
||||
@@ -123,7 +123,7 @@ public static class AbCipDriverFactoryExtensions
|
||||
/// <c>Tag.Timeout</c> setter throws <see cref="ArgumentOutOfRangeException"/> for a
|
||||
/// non-positive value, which would otherwise fault tag creation on every read/write; clamping
|
||||
/// here keeps a misconfigured <c>TimeoutMs: 0</c> running on the default bound instead. (Driver
|
||||
/// timeout hardening, sibling of the S7 R2-01 read-leg fix.)
|
||||
/// timeout hardening, sibling of the S7 read-leg fix.)
|
||||
/// </summary>
|
||||
/// <param name="ms">The operator-supplied timeout in milliseconds, or null.</param>
|
||||
/// <param name="defaultMs">The default applied when <paramref name="ms"/> is null or non-positive.</param>
|
||||
@@ -132,7 +132,7 @@ public static class AbCipDriverFactoryExtensions
|
||||
TimeSpan.FromMilliseconds(ms is int v and > 0 ? v : defaultMs);
|
||||
|
||||
// tagName null = a driver-level (or device-level) config field: the error text names the driver config,
|
||||
// not a tag. Never pass a placeholder like "device" as tagName — it renders as "tag 'device'" (#574).
|
||||
// not a tag. Never pass a placeholder like "device" as tagName — it renders as "tag 'device'".
|
||||
private static T ParseEnum<T>(string? raw, string? tagName, string driverInstanceId, string field,
|
||||
T? fallback = null) where T : struct, Enum
|
||||
{
|
||||
@@ -146,7 +146,7 @@ public static class AbCipDriverFactoryExtensions
|
||||
: $"AB CIP {subject} in '{driverInstanceId}' missing {field}");
|
||||
}
|
||||
// Enum.TryParse also accepts any integer string ("7"), defined or not; an undefined value would
|
||||
// otherwise fall through to a switch's default arm silently (Task 22 review r1 M2).
|
||||
// otherwise fall through to a switch's default arm silently.
|
||||
return Enum.TryParse<T>(raw, ignoreCase: true, out var v) && Enum.IsDefined(v)
|
||||
? v
|
||||
: throw new InvalidOperationException(
|
||||
|
||||
@@ -184,6 +184,8 @@ public sealed class AbCipDriverProbe : IDriverProbe
|
||||
/// but the probe tag name was not found or did not match — sufficient to confirm CIP
|
||||
/// reachability. Returns <c>true</c> for <c>Ok</c> (tag was found).
|
||||
/// </summary>
|
||||
/// <param name="s">The transport status to classify.</param>
|
||||
/// <returns><see langword="true"/> if the status confirms CIP reachability.</returns>
|
||||
internal static bool IsReachableStatus(AbStatus s) => s is AbStatus.Ok or AbStatus.NotFound;
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
/// (<see cref="AbOpcUaStatusMap"/>): timeout, not-found, not-allowed, out-of-bounds, unsupported, type-mismatch
|
||||
/// and device-state-conflict get their specific codes; the remaining transport errors fold into
|
||||
/// <c>BadCommunicationError</c>. (The raw CIP general-status mapper that used to live here had no callers — the
|
||||
/// transports surface an <see cref="AbStatus"/>, not the status byte — and was removed, #580.)
|
||||
/// transports surface an <see cref="AbStatus"/>, not the status byte — and was removed.)
|
||||
/// </summary>
|
||||
public static class AbCipStatusMapper
|
||||
{
|
||||
|
||||
@@ -2,7 +2,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
|
||||
/// <summary>
|
||||
/// A tag's own configuration cannot be turned into a request at all — a malformed tag path, or a
|
||||
/// symbolic name the native CIP encoder refuses (Gitea #570). Raised before any I/O, so it says nothing
|
||||
/// symbolic name the native CIP encoder refuses. Raised before any I/O, so it says nothing
|
||||
/// about the device: the driver reports it per tag as <c>BadConfigurationError</c> and never counts it
|
||||
/// against the device's connect backoff or its health, so healthy tags on the same device keep reading.
|
||||
/// </summary>
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
/// Cache of UDT shape descriptors keyed by <c>(deviceHostAddress, templateInstanceId)</c>.
|
||||
/// Populated on demand during discovery + whole-UDT reads.
|
||||
/// <para><b>Nothing in production clears it.</b> Its only caller was
|
||||
/// <c>AbCipDriver.FlushOptionalCachesAsync</c>, deleted in Gitea #525 as an unconsumed
|
||||
/// <c>AbCipDriver.FlushOptionalCachesAsync</c>, since deleted as an unconsumed
|
||||
/// <c>IDriver</c> member — but that member never had a caller either, so this is not a change in
|
||||
/// behaviour, only in how visible the fact is. The comment here used to claim a device
|
||||
/// <c>ReinitializeAsync</c> also flushed it; no such call ever existed. A UDT redefined on a live
|
||||
|
||||
@@ -6,6 +6,7 @@ internal static class AbCipTransportFactories
|
||||
/// <summary>Returns the tag, enumerator and template-reader factories for <paramref name="transport"/>.</summary>
|
||||
/// <param name="transport">The transport.</param>
|
||||
/// <param name="browsePageDelay">Pause between native symbol-listing pages; null is the compiled default.</param>
|
||||
/// <returns>The tag, enumerator and template-reader factories for the transport.</returns>
|
||||
public static (IAbCipTagFactory Tags, IAbCipTagEnumeratorFactory Enumerators, IAbCipTemplateReaderFactory Templates)
|
||||
For(AbCipTransport transport, TimeSpan? browsePageDelay = null) => transport switch
|
||||
{
|
||||
|
||||
@@ -100,7 +100,7 @@ public static class CipSymbolObjectDecoder
|
||||
// No alignment step: the next entry begins on the byte immediately after the last name
|
||||
// byte, even when that is an odd offset. This used to advance to an even boundary, which
|
||||
// read a correct first entry and then garbage for everything following an odd-length
|
||||
// name — and odd-length names are ordinary (Gitea #468).
|
||||
// name — and odd-length names are ordinary.
|
||||
pos += nameLength;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name)) continue;
|
||||
|
||||
@@ -145,7 +145,7 @@ public sealed record AbCipTagCreateParams(
|
||||
/// no usable port was configured (libplctag then applies its own 44818 default).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ <b><see cref="Port"/> used to be carried here and never consumed</b> (Gitea #534, found in
|
||||
/// ⚠️ <b><see cref="Port"/> used to be carried here and never consumed</b> (found in
|
||||
/// the AbLegacy driver, which had the identical defect). The runtime passed the bare host to
|
||||
/// libplctag, which silently substitutes 44818 — so an authored port did not merely fail to apply,
|
||||
/// it redirected the connection to whatever answers on the default port, which on a plant network
|
||||
|
||||
@@ -9,5 +9,6 @@ internal static class LibplctagPath
|
||||
{
|
||||
/// <summary>Returns <see langword="null"/> for a null, empty or whitespace path; otherwise the path unchanged.</summary>
|
||||
/// <param name="path">The parsed CIP path.</param>
|
||||
/// <returns>The path, or <see langword="null"/> when it is null or blank.</returns>
|
||||
public static string? Normalize(string? path) => string.IsNullOrWhiteSpace(path) ? null : path;
|
||||
}
|
||||
|
||||
@@ -42,13 +42,14 @@ internal static class LibplctagStatusTranslation
|
||||
/// the name or a create attribute does not parse), as opposed to a transport failure.
|
||||
/// </summary>
|
||||
/// <param name="ex">The exception.</param>
|
||||
/// <returns><see langword="true"/> when the exception is a bad-parameter refusal.</returns>
|
||||
public static bool IsTagParameterRefusal(LibPlcTagException ex) =>
|
||||
Enum.TryParse<Status>((ex.Message ?? string.Empty).Trim(), ignoreCase: true, out var s) && s == Status.ErrorBadParam;
|
||||
|
||||
/// <summary>
|
||||
/// Runs a tag's <b>initialisation</b>: <c>ErrorBadParam</c> there means libplctag rejected the tag's own
|
||||
/// name before any I/O, so it is raised as <see cref="AbCipTagConfigurationException"/> (per-tag
|
||||
/// <c>BadConfigurationError</c>, no backoff, no health signal — Gitea #570/#593) instead of falling through
|
||||
/// <c>BadConfigurationError</c>, no backoff, no health signal) instead of falling through
|
||||
/// to the generic <see cref="AbStatus.BadConnection"/> default that backs the whole device off. Only the
|
||||
/// create phase: a BadParam from a read or write on a live handle is not a name problem.
|
||||
/// </summary>
|
||||
|
||||
@@ -9,6 +9,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
/// </summary>
|
||||
internal static class LibplctagListingReader
|
||||
{
|
||||
/// <summary>Reads the raw Symbol Object listing bytes for a <c>@tags</c> pseudo-tag.</summary>
|
||||
/// <param name="deviceParams">Device connection parameters for the listing tag.</param>
|
||||
/// <param name="listingName">The listing pseudo-tag name, e.g. <c>@tags</c> or <c>Program:X.@tags</c>.</param>
|
||||
/// <param name="cancellationToken">Cancels the read.</param>
|
||||
/// <returns>The raw listing bytes.</returns>
|
||||
internal static async Task<byte[]> ReadAsync(
|
||||
AbCipTagCreateParams deviceParams, string listingName, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -31,7 +36,7 @@ internal static class LibplctagListingReader
|
||||
/// Gateway must be <see cref="AbCipTagCreateParams.LibplctagGateway"/> (host:port), never the
|
||||
/// bare <see cref="AbCipTagCreateParams.Gateway"/> — libplctag substitutes 44818 for a bare
|
||||
/// host, so a browse against a device authored on another port silently returns whatever
|
||||
/// answers on 44818 (Gitea #539, the discovery half of #534).
|
||||
/// answers on 44818.
|
||||
/// </remarks>
|
||||
/// <param name="deviceParams">The device's create params.</param>
|
||||
/// <param name="listingName">The listing pseudo-tag name (default <c>@tags</c>).</param>
|
||||
|
||||
@@ -33,11 +33,12 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
||||
|
||||
/// <summary>Builds the (uninitialized) libplctag tag for <paramref name="p"/>.</summary>
|
||||
/// <param name="p">The tag creation parameters.</param>
|
||||
/// <returns>The new, uninitialised libplctag tag.</returns>
|
||||
internal static Tag BuildTag(AbCipTagCreateParams p) =>
|
||||
new Tag
|
||||
{
|
||||
// host:port — see AbLegacyTagCreateParams/AbCipTagCreateParams.LibplctagGateway.
|
||||
// Passing p.Gateway alone drops the authored port onto libplctag's 44818 default (#534).
|
||||
// Passing p.Gateway alone drops the authored port onto libplctag's 44818 default.
|
||||
Gateway = p.LibplctagGateway,
|
||||
Path = LibplctagPath.Normalize(p.CipPath),
|
||||
PlcType = MapPlcType(p.LibplctagPlcAttribute),
|
||||
@@ -54,8 +55,8 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
||||
public Task InitializeAsync(CancellationToken cancellationToken) =>
|
||||
RunReleasingOrphanAsync(() => LibplctagStatusTranslation.WrapInitialize(() => _tag.InitializeAsync(cancellationToken)));
|
||||
|
||||
// libplctag initialises an uninitialised tag on its first read, so this path can orphan one too.
|
||||
/// <inheritdoc />
|
||||
/// <remarks>libplctag initialises an uninitialised tag on its first read, so this path can orphan one too.</remarks>
|
||||
public Task ReadAsync(CancellationToken cancellationToken) =>
|
||||
RunReleasingOrphanAsync(() => _tag.ReadAsync(cancellationToken));
|
||||
|
||||
@@ -103,7 +104,7 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
||||
public int GetSize() => LibplctagStatusTranslation.WrapSync(() => _tag.GetSize());
|
||||
|
||||
/// <summary>
|
||||
/// A copy of the bytes of the last read, for the read-only differential harness (Task 18) to compare
|
||||
/// A copy of the bytes of the last read, for the read-only differential harness to compare
|
||||
/// against the native runtime byte for byte. Not part of <see cref="IAbCipTagRuntime"/>.
|
||||
/// </summary>
|
||||
internal ReadOnlyMemory<byte> RawBuffer => LibplctagStatusTranslation.WrapSync(() => _tag.GetBuffer());
|
||||
@@ -277,7 +278,7 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
|
||||
_tag.SetInt32(0, Convert.ToInt32(value));
|
||||
break;
|
||||
case AbCipDataType.Structure:
|
||||
// Whole-UDT writes are refused by decision (Gitea #611), not deferred: a structure
|
||||
// Whole-UDT writes are refused by decision, not deferred: a structure
|
||||
// write overwrites every member, hidden BOOL hosts and padding included. Members are
|
||||
// written via their own dotted tag paths through the atomic cases above. The driver
|
||||
// refuses a Structure tag before it ever reaches here; this is the backstop.
|
||||
|
||||
@@ -36,8 +36,11 @@ internal sealed class LibplctagTemplateReader : IAbCipTemplateReader
|
||||
/// Gateway must be <see cref="AbCipTagCreateParams.LibplctagGateway"/> (host:port), never the
|
||||
/// bare <see cref="AbCipTagCreateParams.Gateway"/> — libplctag substitutes 44818 for a bare
|
||||
/// host, so a browse against a device authored on another port silently returns whatever
|
||||
/// answers on 44818 (Gitea #539, the discovery half of #534).
|
||||
/// answers on 44818.
|
||||
/// </remarks>
|
||||
/// <param name="deviceParams">The device's tag creation parameters.</param>
|
||||
/// <param name="templateInstanceId">The template instance id of the UDT to read.</param>
|
||||
/// <returns>The uninitialized libplctag tag for the template pseudo-tag.</returns>
|
||||
internal static Tag BuildTemplateTag(AbCipTagCreateParams deviceParams, uint templateInstanceId) => new()
|
||||
{
|
||||
Gateway = deviceParams.LibplctagGateway,
|
||||
|
||||
@@ -39,7 +39,7 @@ internal static class NativeAbCipDevice
|
||||
/// Micro800 is <b>always</b> <see cref="CipRouting.Connected"/> with an empty route, even though its
|
||||
/// family profile says <c>SupportsConnectedMessaging: false</c>: libplctag opens a Large Forward
|
||||
/// Open with connection path <c>20 02 24 01</c> and reads connected (Captures/micro800-read-dint.hex,
|
||||
/// Captures/README.md "Routing observed"; ruling R12-1 — the capture beats the profile flag).
|
||||
/// Captures/README.md "Routing observed"; the capture beats the profile flag).
|
||||
/// Every other family follows the profile: connected-capable ⇒ Forward Open; otherwise
|
||||
/// Unconnected Send when a route is configured, bare SendRRData when it is not.
|
||||
/// </remarks>
|
||||
|
||||
@@ -162,6 +162,8 @@ internal static class NativeLease
|
||||
private static readonly TimeSpan DisposeWait = TimeSpan.FromSeconds(2);
|
||||
|
||||
/// <summary>Releases, waiting at most 2 s; never throws, whatever the release delegate does.</summary>
|
||||
/// <param name="release">The delegate that releases the lease.</param>
|
||||
/// <param name="logger">The logger used to report a failed release.</param>
|
||||
public static void ReleaseBounded(Func<ValueTask> release, ILogger logger)
|
||||
{
|
||||
try { release().AsTask().Wait(DisposeWait); }
|
||||
|
||||
@@ -16,7 +16,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Not thread-safe: the driver serialises Read → GetStatus → Decode (and Encode → Write → GetStatus)
|
||||
/// per runtime under its per-runtime lock (05/STAB-4), exactly as it does for libplctag.
|
||||
/// per runtime under its per-runtime lock, exactly as it does for libplctag.
|
||||
/// </remarks>
|
||||
internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
{
|
||||
@@ -94,19 +94,17 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
/// <summary>The trailing bit selector split off the tag name (<c>Tag.5</c>), if any.</summary>
|
||||
internal int? ImplicitBit => _implicitBit;
|
||||
|
||||
// Performs one read to prove the tag exists: libplctag's InitializeAsync errors for an unknown
|
||||
// tag, and the driver's backoff classification depends on create throwing AbStatus.NotFound.
|
||||
//
|
||||
// A name the encoder refuses throws AbCipTagConfigurationException before any lease: it is this
|
||||
// tag's configuration, not the device, and must not black out the device's other tags. A read the
|
||||
// controller answers with a deterministic refusal (AbStatus.TypeMismatch / AbStatus.Unsupported)
|
||||
// proves the tag exists, so it completes the initialization with that status rather than throwing:
|
||||
// a throw here makes the driver dispose the runtime and re-create it - and, for a sole lease
|
||||
// holder, re-Forward-Open - on every poll (mistype churn). The driver's own read reports the
|
||||
// refusal.
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// <para>Performs one read to prove the tag exists: libplctag's <c>InitializeAsync</c> errors for an
|
||||
/// unknown tag, and the driver's 05/STAB-8 backoff classification depends on create throwing
|
||||
/// <see cref="AbStatus.NotFound"/>.</para>
|
||||
/// <para>A name the encoder refuses throws <see cref="AbCipTagConfigurationException"/> before any
|
||||
/// lease (Gitea #570): it is this tag's configuration, not the device, and must not black out the
|
||||
/// device's other tags. A read the controller answers with a deterministic refusal
|
||||
/// (<see cref="AbStatus.TypeMismatch"/> / <see cref="AbStatus.Unsupported"/>) proves the tag exists,
|
||||
/// so it completes the initialization with that status rather than throwing: a throw here makes the
|
||||
/// driver dispose the runtime and re-create it — and, for a sole lease holder, re-Forward-Open — on
|
||||
/// every poll (the mistype churn of #570). The driver's own read reports the refusal.</para>
|
||||
/// </remarks>
|
||||
public async Task InitializeAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
@@ -167,10 +165,12 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upper bound on one element's wire bytes for the scheduler's reply budget (#582); 0 when the
|
||||
/// 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.
|
||||
/// BOOL counts 4 (a packed BOOL array reads back as DWORDs).
|
||||
/// </summary>
|
||||
/// <param name="type">The authored data type, or null when unknown.</param>
|
||||
/// <returns>The expected wire bytes per element, or 0 when unknown.</returns>
|
||||
internal static int ExpectedElementBytes(AbCipDataType? type) => type switch
|
||||
{
|
||||
AbCipDataType.Bool or AbCipDataType.DInt or AbCipDataType.UDInt or AbCipDataType.Real or AbCipDataType.Dt => 4,
|
||||
@@ -255,7 +255,7 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
public int GetSize() => _data.Length;
|
||||
|
||||
/// <summary>
|
||||
/// The bytes of the last read, for the read-only differential harness (Task 18) to compare against
|
||||
/// The bytes of the last read, for the read-only differential harness to compare against
|
||||
/// libplctag byte for byte. Not part of <see cref="IAbCipTagRuntime"/>.
|
||||
/// </summary>
|
||||
internal ReadOnlyMemory<byte> RawBuffer => _data;
|
||||
@@ -384,7 +384,7 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases the connection lease without blocking (Gitea #579) — the driver calls this under the
|
||||
/// Releases the connection lease without blocking — the driver calls this under the
|
||||
/// tag's lock, which used to be held for up to 2 s while the last release's Forward Close ran. An
|
||||
/// immediate re-init still cannot open a second session while that close is in flight (review
|
||||
/// finding 22): the pool registers the close synchronously and holds any successor connection
|
||||
@@ -434,7 +434,7 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
if (returned == expected) return false;
|
||||
// Micro800 BYTE/WORD/LWORD (0xD1/0xD2/0xD4) are outside LogixTypeCode's width table but are
|
||||
// integers of a known width: they are checked like any integer, so a REAL / LREAL / narrower
|
||||
// integer authored over an LWORD is a mismatch rather than garbage decoded as Good (Gitea #570).
|
||||
// integer authored over an LWORD is a mismatch rather than garbage decoded as Good.
|
||||
// Any other code without a known width (DATE, DATE_AND_TIME, structures) stays exempt.
|
||||
if (LogixTypeCode.ByteWidth(returned) < 0 && IntegerWidth(returned) is null) return false;
|
||||
if (_p.AuthoredType == AbCipDataType.Bool)
|
||||
@@ -513,7 +513,7 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
|
||||
|
||||
/// <summary>
|
||||
/// Characters a STRING of <paramref name="structureBytes"/> holds. The built-in 88-byte STRING holds
|
||||
/// 82 — its last 2 bytes are alignment padding, not DATA (Gitea #570). Any other size is a user
|
||||
/// 82 — its last 2 bytes are alignment padding, not DATA. Any other size is a user
|
||||
/// string type whose DATA length the read alone cannot separate from its padding (up to 3 bytes),
|
||||
/// so it keeps the buffer-derived <c>size − 4</c>.
|
||||
/// </summary>
|
||||
|
||||
@@ -26,14 +26,12 @@ internal sealed class SymbolListingEnumerator : IAbCipTagEnumerator
|
||||
public SymbolListingEnumerator(Func<AbCipTagCreateParams, string, CancellationToken, Task<byte[]>> readListing)
|
||||
=> _readListing = readListing;
|
||||
|
||||
// A controller's @tags listing holds controller-scope tags plus one system entry per program
|
||||
// (Program:UN01_Press); the program's own tags are listed only by Program:<name>.@tags. So the
|
||||
// controller listing is read first, then each program's listing, decoded with that program as the
|
||||
// scope. A program whose listing cannot be read is skipped - browse is best-effort and one
|
||||
// unreadable program must not cost the others.
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// A controller's <c>@tags</c> listing holds controller-scope tags plus one system entry per
|
||||
/// program (<c>Program:UN01_Press</c>); the program's own tags are listed only by
|
||||
/// <c>Program:<name>.@tags</c>. So the controller listing is read first, then each program's
|
||||
/// listing, decoded with that program as the scope. A program whose listing cannot be read is
|
||||
/// skipped — browse is best-effort and one unreadable program must not cost the others.
|
||||
/// </remarks>
|
||||
public async IAsyncEnumerable<AbCipDiscoveredTag> EnumerateAsync(
|
||||
AbCipTagCreateParams deviceParams,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
|
||||
@@ -21,9 +21,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
/// <item><c>O:1/2</c> — output file, slot 1, bit 2.</item>
|
||||
/// <item><c>S:1</c> — status file, word 1.</item>
|
||||
/// <item><c>L9:0</c> — long-integer file (SLC 5/05+, 32-bit).</item>
|
||||
/// <item><c>I:017/07</c> — PLC-5 only: I/O word + bit indices are <b>octal</b> (#244); stored decimal.</item>
|
||||
/// <item><c>RTC:0.HR</c> — MicroLogix only: function file (RTC/HSC/DLS/MMI/PTO/PWM/STI/EII/IOS/BHI, #245).</item>
|
||||
/// <item><c>N7:[N7:0]</c>, <c>N[N7:0]:5</c> — indirect addressing (#247 parse, #646 runtime): the driver reads the index first and composes the concrete address.</item>
|
||||
/// <item><c>I:017/07</c> — PLC-5 only: I/O word + bit indices are <b>octal</b>; stored decimal.</item>
|
||||
/// <item><c>RTC:0.HR</c> — MicroLogix only: function file (RTC/HSC/DLS/MMI/PTO/PWM/STI/EII/IOS/BHI).</item>
|
||||
/// <item><c>N7:[N7:0]</c>, <c>N[N7:0]:5</c> — indirect addressing (parsed here, resolved at runtime): the driver reads the index first and composes the concrete address.</item>
|
||||
/// </list>
|
||||
/// <para>The libplctag transport takes <see cref="ToLibplctagName"/> as its <c>name=...</c> attribute and the
|
||||
/// native transport re-parses the same string, so both are handed identical (decimal) numbers. This parser
|
||||
@@ -42,7 +42,7 @@ public sealed record AbLegacyAddress(
|
||||
/// <summary>
|
||||
/// True when the file number or the word number is sourced from another PCCC address evaluated
|
||||
/// at runtime (<c>N7:[N7:0]</c> or <c>N[N7:0]:5</c>). Neither transport can encode the bracket
|
||||
/// form, so the driver (#646) pre-reads the index, substitutes it, and hands the transport the
|
||||
/// form, so the driver pre-reads the index, substitutes it, and hands the transport the
|
||||
/// concrete address; an indirect name never reaches a transport.
|
||||
/// </summary>
|
||||
public bool IsIndirect => IndirectFileSource is not null || IndirectWordSource is not null;
|
||||
@@ -289,6 +289,8 @@ public sealed record AbLegacyAddress(
|
||||
/// MicroLogix 1100/1400 function-file prefixes — each a single fixed instance with a sub-element
|
||||
/// catalogue (<c>AbLegacyFunctionFile</c>).
|
||||
/// </summary>
|
||||
/// <param name="letter">The file-type prefix to test.</param>
|
||||
/// <returns><see langword="true"/> when <paramref name="letter"/> names a MicroLogix function file.</returns>
|
||||
public static bool IsFunctionFileLetter(string letter) => letter switch
|
||||
{
|
||||
"RTC" or "HSC" or "DLS" or "MMI" or "PTO" or "PWM" or "STI" or "EII" or "IOS" or "BHI" => true,
|
||||
|
||||
@@ -34,7 +34,7 @@ public sealed class AbLegacyDriverOptions
|
||||
/// <para>Changing this takes effect when the driver instance is RECREATED, not on
|
||||
/// <c>ReinitializeAsync</c>: the tag factory is chosen once in the constructor and held across
|
||||
/// Shutdown+Initialize. A deploy that changes the driver config stops and respawns the instance
|
||||
/// (<c>DriverSpawnPlanner</c>, #516), so an operator edit does recreate it.</para>
|
||||
/// (<c>DriverSpawnPlanner</c>), so an operator edit does recreate it.</para>
|
||||
/// </summary>
|
||||
/// <remarks>Null is never written: an absent key IS the "use the default" encoding, so a round-trip
|
||||
/// through any serializer (the AdminUI form included) keeps a defaulted driver defaulted rather than
|
||||
|
||||
@@ -64,7 +64,7 @@ public sealed record AbLegacyPlcFamilyProfile(
|
||||
MaxTagBytes: 240, // DF1 full-duplex packet limit at 264 bytes, PCCC-over-EIP caps lower
|
||||
SupportsStringFile: true,
|
||||
SupportsLongFile: false, // PLC-5 predates L files
|
||||
OctalIoAddressing: true); // RSLogix 5 displays I:/O: word + bit indices as octal (#244)
|
||||
OctalIoAddressing: true); // RSLogix 5 displays I:/O: word + bit indices as octal
|
||||
|
||||
/// <summary>
|
||||
/// Logix ControlLogix / CompactLogix accessed through the legacy PCCC compatibility layer.
|
||||
|
||||
+7
-7
@@ -67,7 +67,7 @@ public static class AbLegacyTagDefinitionFactory
|
||||
return false;
|
||||
var address = addr.GetString();
|
||||
if (string.IsNullOrWhiteSpace(address)) return false;
|
||||
// Strict enum read (R2-11 Phase C): a typo'd dataType rejects the tag (→ BadNodeIdUnknown)
|
||||
// Strict enum read: a typo'd dataType rejects the tag (→ BadNodeIdUnknown)
|
||||
// instead of silently defaulting to a wrong-width Good.
|
||||
if (!TagConfigJson.TryReadEnumStrict(root, "dataType", AbLegacyDataType.Int, out var dataType)) return false;
|
||||
int? arrayLength = null;
|
||||
@@ -78,7 +78,7 @@ public static class AbLegacyTagDefinitionFactory
|
||||
arrayLength = Math.Min(rawLength, AbLegacyArray.MaxElements);
|
||||
}
|
||||
// "writable" defaults to true when absent (today's value) — makes read-only equipment tags
|
||||
// authorable (UNDER-6).
|
||||
// authorable.
|
||||
var writable = TagConfigJson.ReadWritable(root);
|
||||
def = new AbLegacyTagDefinition(
|
||||
Name: rawPath, DeviceHostAddress: "", Address: address,
|
||||
@@ -126,15 +126,15 @@ public static class AbLegacyTagDefinitionFactory
|
||||
private const string InspectionRawPath = "(inspect)";
|
||||
|
||||
/// <summary>
|
||||
/// Deploy-time inspection (05/CONV-2): warns for a blob <see cref="FromTagConfig"/> would
|
||||
/// Deploy-time inspection: warns for a blob <see cref="FromTagConfig"/> would
|
||||
/// <b>reject</b> — a structurally unparseable or non-object document, an absent / non-string /
|
||||
/// blank <c>address</c>, or a present-but-invalid <c>dataType</c> (the runtime enum read is STRICT
|
||||
/// since R2-11 Phase C, so a typo rejects the tag). Such a tag is skipped by the driver and reads
|
||||
/// blank <c>address</c>, or a present-but-invalid <c>dataType</c> (the runtime enum read is STRICT,
|
||||
/// so a typo rejects the tag). Such a tag is skipped by the driver and reads
|
||||
/// <c>BadNodeIdUnknown</c> forever. Empty when the mapper accepts the blob, when it is blank, or
|
||||
/// when it is not an equipment-tag object. Never throws.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>The verdict comes from <see cref="FromTagConfig"/>, not from a mirror</b> (Gitea #551) — see
|
||||
/// <b>The verdict comes from <see cref="FromTagConfig"/>, not from a mirror</b> — see
|
||||
/// <c>ModbusTagDefinitionFactory.Inspect</c>'s remarks for the full argument.
|
||||
/// <para>
|
||||
/// An <c>arrayLength</c> above <see cref="AbLegacyArray.MaxElements"/> is deliberately NOT
|
||||
@@ -160,7 +160,7 @@ public static class AbLegacyTagDefinitionFactory
|
||||
public static IReadOnlyList<string> Inspect(string reference, AbLegacyPlcFamily? family)
|
||||
{
|
||||
var warnings = new List<string>();
|
||||
// Gitea #556 item 1: the framing check TRIMS FIRST. FromTagConfig hands the blob straight to
|
||||
// The framing check TRIMS FIRST. FromTagConfig hands the blob straight to
|
||||
// JsonDocument.Parse, which tolerates leading whitespace — so an untrimmed check let " {…}" be
|
||||
// read (and possibly REFUSED) by the driver while drawing zero deploy warnings. A reference that
|
||||
// still does not start with '{' after trimming is not an equipment-tag TagConfig at all and keeps
|
||||
|
||||
@@ -15,14 +15,14 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
|
||||
IHostConnectivityProbe, IPerCallHostResolver, ISkippedTagReporter, IDisposable, IAsyncDisposable
|
||||
{
|
||||
/// <summary>Authored tags this driver could not build into its poll table (Gitea #541).</summary>
|
||||
/// <summary>Authored tags this driver could not build into its poll table.</summary>
|
||||
private readonly SkippedTagLedger _skippedTags = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public SkippedTagReport? GetSkippedTags() => _skippedTags.Snapshot();
|
||||
|
||||
/// <summary>Mutable so <see cref="InitializeAsync"/> can adopt a re-parsed config on reinitialize
|
||||
/// (#516). Only ever written on the init path, before any reader/session uses it.</summary>
|
||||
/// <summary>Mutable so <see cref="InitializeAsync"/> can adopt a re-parsed config on reinitialize.
|
||||
/// Only ever written on the init path, before any reader/session uses it.</summary>
|
||||
private AbLegacyDriverOptions _options;
|
||||
private readonly string _driverInstanceId;
|
||||
private readonly IAbLegacyTagFactory _tagFactory;
|
||||
@@ -109,11 +109,11 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
// Set once the explicit-Native-on-a-limited-family Warning has been logged for this instance.
|
||||
private bool _limitedFamilyWarned;
|
||||
|
||||
/// <summary>Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide (05/STAB-8).</summary>
|
||||
/// <summary>Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide.</summary>
|
||||
private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// 05/STAB-9 — routes a poll-loop reader failure to the driver health surface: logs it and
|
||||
/// Routes a poll-loop reader failure to the driver health surface: logs it and
|
||||
/// degrades to <see cref="DriverState.Degraded"/> preserving <c>LastSuccessfulRead</c>.
|
||||
/// Never downgrades a <see cref="DriverState.Faulted"/> state (a stronger, config-level signal).
|
||||
/// </summary>
|
||||
@@ -145,7 +145,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
/// <inheritdoc />
|
||||
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
// #630: InitializeAsync can run on a live driver without a teardown first (the IDriver contract
|
||||
// InitializeAsync can run on a live driver without a teardown first (the IDriver contract
|
||||
// allows it, and a reconnect retry can overlap a running attempt). Release the previous device states' probe loops + runtimes BEFORE the
|
||||
// overwrite below, or they are orphaned beyond the reach of ShutdownAsync. Deliberately NOT
|
||||
// ShutdownAsync: that also drops _poll and every subscription, which are owned by the shutdown path (the actor re-subscribes after every successful init).
|
||||
@@ -156,7 +156,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
_driverInstanceId, _transportDescription, _tagFactory.GetType().Name);
|
||||
try
|
||||
{
|
||||
// #516: re-parse the supplied DriverConfig so a config change delivered through the IDriver
|
||||
// re-parse the supplied DriverConfig so a config change delivered through the IDriver
|
||||
// contract is honoured. Without this the driver keeps serving the options it was CONSTRUCTED
|
||||
// with, so an operator's edit is discarded while the deployment still seals green.
|
||||
if (HasConfigBody(driverConfigJson))
|
||||
@@ -164,7 +164,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
|
||||
// Native is opt-in for every family (the default is libplctag); an operator may pick it explicitly
|
||||
// even for PLC-5 / LogixPccc. Allowed, but the limitation is client-visible, so name it ONCE per instance —
|
||||
// not on every init, which includes every comms-loss reconnect retry (Task 22 review r1 M4).
|
||||
// not on every init, which includes every comms-loss reconnect retry.
|
||||
if (!_limitedFamilyWarned
|
||||
&& _transport == AbLegacyTransport.Native
|
||||
&& AbLegacyTransportResolver.NativeLimitedFamilies(_options.Devices) is { Count: > 0 } limited)
|
||||
@@ -317,7 +317,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _poll.DisposeAsync().ConfigureAwait(false);
|
||||
// STAB-6: cancel the probe loops, JOIN them (bounded), then dispose the CTSs and runtimes.
|
||||
// Cancel the probe loops, JOIN them (bounded), then dispose the CTSs and runtimes.
|
||||
// The loop used to be fire-and-forget, so one parked in a probe-runtime create/read could
|
||||
// outlive shutdown and keep a libplctag handle alive past DisposeRuntimes.
|
||||
foreach (var state in _devices.Values)
|
||||
@@ -397,7 +397,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
}
|
||||
if (preParsed.IsIndirect)
|
||||
{
|
||||
// Indirect (#646): read the index word(s) NOW (never cached - the index can change between
|
||||
// Indirect: read the index word(s) NOW (never cached - the index can change between
|
||||
// polls), compose the concrete address, then run the normal path against it.
|
||||
var res = await ResolveIndirectAsync(device, def, preParsed, cancellationToken).ConfigureAwait(false);
|
||||
if (res.Def is null)
|
||||
@@ -464,7 +464,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch (AbTransportException ex) when (IsLocalRefusal(ex))
|
||||
{
|
||||
// The runtime refused the operation before the wire (e.g. a native PLC-5 non-N read, #571).
|
||||
// The runtime refused the operation before the wire (e.g. a native PLC-5 non-N read).
|
||||
// Not a comms fault: BadNotSupported, keep the handle, leave health alone.
|
||||
results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNotSupported, null, now);
|
||||
}
|
||||
@@ -521,7 +521,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
continue;
|
||||
}
|
||||
|
||||
// Indirect (#646): resolve the index first (non-atomic with the write - the index may change
|
||||
// Indirect: resolve the index first (non-atomic with the write - the index may change
|
||||
// between the two PLC accesses), then write the concrete address through the normal path.
|
||||
if (parsed.IsIndirect)
|
||||
{
|
||||
@@ -541,7 +541,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
// A write the transport refuses outright (native: a T/C/R status-bit sub-element such as T4:0.DN,
|
||||
// or any PLC-5 write) is answered BEFORE a runtime is created. Creating one first costs an init
|
||||
// READ, and against a device lacking that file the read failure surfaced as a comms fault —
|
||||
// BadCommunicationError, evicted handle, Degraded health — instead of the refusal (#593 follow-up).
|
||||
// BadCommunicationError, evicted handle, Degraded health — instead of the refusal.
|
||||
// Ahead of the bit-RMW branch too, which would otherwise read the parent word first.
|
||||
if (parsed is not null && _tagFactory.WriteRefusal(CreateParamsFor(device, def, parsed)) is not null)
|
||||
{
|
||||
@@ -610,7 +610,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
catch (AbTransportException ex) when (IsLocalRefusal(ex))
|
||||
{
|
||||
// The runtime refused the write before the wire — a native status-bit sub-element write or a
|
||||
// PLC-5 write (#571). Not a comms fault: BadNotSupported, keep the handle, leave health alone.
|
||||
// PLC-5 write. Not a comms fault: BadNotSupported, keep the handle, leave health alone.
|
||||
results[i] = new WriteResult(AbLegacyStatusMapper.BadNotSupported);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -787,15 +787,14 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
|
||||
// ---- IPerCallHostResolver ----
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a reference to the device host that keys its per-host resilience
|
||||
/// (bulkhead / circuit breaker). CONV-4: routes through
|
||||
/// <see cref="EquipmentTagRefResolver{TDef}"/> so an equipment-tag reference (raw TagConfig
|
||||
/// JSON) resolves to its OWN authored device rather than the first-device fallback. The
|
||||
/// resolver caches parses, so this adds no per-call cost after first use.
|
||||
/// </summary>
|
||||
/// <param name="fullReference">The tag reference (authored name or equipment-tag JSON).</param>
|
||||
/// <returns>The device host key.</returns>
|
||||
/// <inheritdoc />
|
||||
// Resolve a reference to the device host that keys its per-host resilience
|
||||
// (bulkhead / circuit breaker). Routes through
|
||||
// so an equipment-tag reference (raw TagConfig
|
||||
// JSON) resolves to its OWN authored device rather than the first-device fallback. The
|
||||
// resolver caches parses, so this adds no per-call cost after first use.
|
||||
// The tag reference (authored name or equipment-tag JSON).
|
||||
// The device host key.
|
||||
public string ResolveHost(string fullReference)
|
||||
{
|
||||
if (_resolver.TryResolve(fullReference, out var def) && !string.IsNullOrEmpty(def.DeviceHostAddress))
|
||||
@@ -869,7 +868,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
private const int MaxIndirectFile = 999;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an indirect address (#646). Reads each index source through the same runtime/transport path
|
||||
/// Resolves an indirect address. Reads each index source through the same runtime/transport path
|
||||
/// (same factory, so libplctag and Native alike), range-checks it, and composes a concrete tag definition
|
||||
/// whose <c>Name</c> is keyed by the resolved address so the normal runtime cache applies. Nothing about the
|
||||
/// index is cached: every call re-reads it. A bad index is <c>BadOutOfRange</c> (per-tag, no health impact);
|
||||
@@ -1046,6 +1045,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
/// all PLC-5 writes), and libplctag's ErrorUnsupported maps to it too. It says nothing about the
|
||||
/// device's reachability, so it must not evict the handle or degrade driver health.
|
||||
/// </summary>
|
||||
/// <param name="ex">The exception to classify.</param>
|
||||
/// <returns><see langword="true"/> if the exception is a local refusal that never reached the wire.</returns>
|
||||
internal static bool IsLocalRefusal(Exception ex) =>
|
||||
ex is AbTransportException { Status: AbStatus.Unsupported };
|
||||
|
||||
@@ -1235,7 +1236,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
public System.Collections.Concurrent.ConcurrentDictionary<string, IAbLegacyTagRuntime> ParentRuntimes { get; } =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Indirect tag name to the resolved-address runtime key it last used (#646), so a moved index evicts its stale handle.</summary>
|
||||
/// <summary>Indirect tag name to the resolved-address runtime key it last used, so a moved index evicts its stale handle.</summary>
|
||||
public System.Collections.Concurrent.ConcurrentDictionary<string, string> IndirectLastName { get; } =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -1302,7 +1303,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
|
||||
public CancellationTokenSource? ProbeCts { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the probe loop task, retained so shutdown can join it (STAB-6).
|
||||
/// Gets or sets the probe loop task, retained so shutdown can join it.
|
||||
/// </summary>
|
||||
public Task? ProbeTask { get; set; }
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ public static class AbLegacyDriverFactoryExtensions
|
||||
/// <summary>
|
||||
/// Parses an AB Legacy <c>DriverConfig</c> JSON document into typed options. Extracted so
|
||||
/// <see cref="AbLegacyDriver.InitializeAsync"/> can re-parse a CHANGED config on reinitialize
|
||||
/// (Gitea #516) instead of serving the options it was constructed with — which silently discarded
|
||||
/// instead of serving the options it was constructed with — which silently discarded
|
||||
/// every operator edit while the deployment still sealed green.
|
||||
/// </summary>
|
||||
/// <param name="driverInstanceId">The unique driver instance identifier.</param>
|
||||
@@ -113,7 +113,7 @@ public static class AbLegacyDriverFactoryExtensions
|
||||
/// <c>Tag.Timeout</c> setter throws <see cref="ArgumentOutOfRangeException"/> for a
|
||||
/// non-positive value, which would otherwise fault tag creation on every read/write; clamping
|
||||
/// here keeps a misconfigured <c>TimeoutMs: 0</c> running on the default bound instead. (Driver
|
||||
/// timeout hardening, sibling of the S7 R2-01 read-leg fix.)
|
||||
/// timeout hardening, sibling of the S7 read-leg fix.)
|
||||
/// </summary>
|
||||
/// <param name="ms">The operator-supplied timeout in milliseconds, or null.</param>
|
||||
/// <param name="defaultMs">The default applied when <paramref name="ms"/> is null or non-positive.</param>
|
||||
@@ -130,7 +130,7 @@ public static class AbLegacyDriverFactoryExtensions
|
||||
throw new InvalidOperationException(
|
||||
$"AB Legacy {(tagName is null ? "config" : $"tag '{tagName}'")} in '{driverInstanceId}' missing {field}");
|
||||
}
|
||||
// Enum.TryParse also accepts any integer string ("7"), defined or not (Task 22 review r1 M2).
|
||||
// Enum.TryParse also accepts any integer string ("7"), defined or not.
|
||||
return Enum.TryParse<T>(raw, ignoreCase: true, out var v) && Enum.IsDefined(v)
|
||||
? v
|
||||
: throw new InvalidOperationException(
|
||||
|
||||
@@ -98,7 +98,7 @@ public sealed class AbLegacyDriverProbe : IDriverProbe
|
||||
?? FirstProbeableTagAddress(opts, firstDevice)
|
||||
?? "S:0";
|
||||
|
||||
// Normalise through the same helper as the driver's probe loop (#660): PLC-5 octal I/O -> decimal.
|
||||
// Normalise through the same helper as the driver's probe loop: PLC-5 octal I/O -> decimal.
|
||||
// An unparseable address is a clean failure here — the factory would otherwise throw outside the try.
|
||||
var normalisedName = AbLegacyProbeAddress.Normalise(tagName, firstDevice?.PlcFamily ?? AbLegacyPlcFamily.Slc500);
|
||||
if (normalisedName is null)
|
||||
|
||||
@@ -3,7 +3,7 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
|
||||
/// <summary>
|
||||
/// MicroLogix 1100/1400 function-file sub-element catalogue (#245): the driver-surface type of
|
||||
/// MicroLogix 1100/1400 function-file sub-element catalogue: the driver-surface type of
|
||||
/// <c>RTC:0.HR</c>, <c>HSC:0.ACC</c>, ... Covers the commonly-addressed members per file — not exhaustive
|
||||
/// (Rockwell defines 30+ on RTC alone). <b>Unverified on the wire:</b> the layouts are transcribed from the
|
||||
/// MicroLogix instruction-set reference; neither transport has been captured against these files and the
|
||||
|
||||
@@ -6,11 +6,14 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
/// The single authority that turns an operator-authored probe address into the name handed to the tag
|
||||
/// factory: family-aware parse (PLC-5 <c>I:</c>/<c>O:</c> octal indices become decimal) then
|
||||
/// <see cref="AbLegacyAddress.ToLibplctagName"/> — the same normalisation tag paths get. Shared by the
|
||||
/// driver's probe loop and the Test Connect probe (#660).
|
||||
/// driver's probe loop and the Test Connect probe.
|
||||
/// </summary>
|
||||
internal static class AbLegacyProbeAddress
|
||||
{
|
||||
/// <summary>Returns the normalised name, or null when <paramref name="address"/> does not parse for the family.</summary>
|
||||
/// <param name="address">The configured probe address.</param>
|
||||
/// <param name="family">The PLC family used to parse the address.</param>
|
||||
/// <returns>The normalised name, or null when the address does not parse.</returns>
|
||||
public static string? Normalise(string? address, AbLegacyPlcFamily family) =>
|
||||
AbLegacyAddress.TryParse(address, family)?.ToLibplctagName();
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ public sealed record AbLegacyTagCreateParams(
|
||||
/// no usable port was configured (libplctag then applies its own 44818 default).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// ⚠️ <b><see cref="Port"/> used to be carried here and never consumed</b> (Gitea #534). The runtime
|
||||
/// ⚠️ <b><see cref="Port"/> used to be carried here and never consumed</b>. The runtime
|
||||
/// passed the bare host to libplctag, which silently substitutes 44818 — so an authored port was
|
||||
/// not merely ignored, it redirected the connection to whatever answers on the default port. On the
|
||||
/// test rig that was a different simulator of a different PLC family, which is how the AbLegacy
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
|
||||
/// <summary>
|
||||
/// Translates a canonical AbLegacy address (<see cref="AbLegacyAddress.ToLibplctagName"/> — what operators
|
||||
/// author and the native transport parses) into the <c>name=</c> form libplctag's PCCC parser accepts, so both
|
||||
/// transports address the same file type / file number / element / sub-element / bit (Gitea #659).
|
||||
/// transports address the same file type / file number / element / sub-element / bit.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>libplctag 2.6.0 (<c>libplctag.NativeImport</c> 1.0.41, bundled by the <c>libplctag</c> 1.5.2 package),
|
||||
@@ -49,7 +49,7 @@ internal static class LibplctagLegacyName
|
||||
/// True when <see cref="Translate"/> changes <paramref name="canonical"/>: a file number supplied for
|
||||
/// <c>S:</c>/<c>O:</c>/<c>I:</c> (incl. bit forms such as <c>O:0/3</c>) or a status bit rewritten to control word +
|
||||
/// bit (<c>R6:0.DN</c> → <c>R6:0.CON/13</c>; R <c>EN/EU/DN/EM/ER/UL/IN/FD</c>, counter <c>UA</c>). A WRITE to such an
|
||||
/// address is refused (<see cref="LibplctagLegacyTagRuntime.WriteRefusal"/>): before #659 libplctag could not
|
||||
/// address is refused (<see cref="LibplctagLegacyTagRuntime.WriteRefusal"/>): previously libplctag could not
|
||||
/// even create these tags, so no write ever reached the PLC, and the read fix must not turn libplctag — the
|
||||
/// default transport — into a new way to drive outputs, status-file words or control words.
|
||||
/// </summary>
|
||||
|
||||
@@ -37,17 +37,18 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
|
||||
|
||||
/// <summary>Builds the (uninitialized) libplctag tag for <paramref name="p"/>.</summary>
|
||||
/// <param name="p">The tag creation parameters.</param>
|
||||
/// <returns>The uninitialized libplctag tag.</returns>
|
||||
internal static Tag BuildTag(AbLegacyTagCreateParams p) =>
|
||||
new Tag
|
||||
{
|
||||
// host:port — see AbLegacyTagCreateParams/AbCipTagCreateParams.LibplctagGateway.
|
||||
// Passing p.Gateway alone drops the authored port onto libplctag's 44818 default (#534).
|
||||
// Passing p.Gateway alone drops the authored port onto libplctag's 44818 default.
|
||||
Gateway = p.LibplctagGateway,
|
||||
Path = LibplctagPath.Normalize(p.CipPath),
|
||||
PlcType = MapPlcType(p.LibplctagPlcAttribute),
|
||||
Protocol = Protocol.ab_eip, // PCCC-over-EIP; libplctag routes via the PlcType-specific PCCC layer
|
||||
// libplctag's PCCC parser needs explicit O0/I1/S2 file numbers and has no R-file status-bit mnemonics;
|
||||
// translate so it addresses what the native transport does (#659).
|
||||
// translate so it addresses what the native transport does.
|
||||
Name = LibplctagLegacyName.Translate(p.TagName),
|
||||
Timeout = p.Timeout,
|
||||
ElementCount = p.ElementCount > 1 ? p.ElementCount : null,
|
||||
@@ -57,14 +58,14 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
|
||||
public Task InitializeAsync(CancellationToken cancellationToken) =>
|
||||
RunReleasingOrphanAsync(() => _tag.InitializeAsync(cancellationToken));
|
||||
|
||||
// libplctag initialises an uninitialised tag on its first read, so this path can orphan one too.
|
||||
/// <inheritdoc />
|
||||
/// <remarks>libplctag initialises an uninitialised tag on its first read, so this path can orphan one too.</remarks>
|
||||
public Task ReadAsync(CancellationToken cancellationToken) =>
|
||||
RunReleasingOrphanAsync(() => _tag.ReadAsync(cancellationToken));
|
||||
|
||||
// Throws the WriteRefusal refusal (Unsupported) before anything is sent — defence in depth;
|
||||
// the driver asks the factory first and never creates a runtime for a refused write.
|
||||
/// <inheritdoc />
|
||||
/// <remarks>Throws the <see cref="WriteRefusal"/> refusal (Unsupported) before anything is sent — defence in depth;
|
||||
/// the driver asks the factory first and never creates a runtime for a refused write.</remarks>
|
||||
public Task WriteAsync(CancellationToken cancellationToken) =>
|
||||
_writeRefusal is { } refusal
|
||||
? Task.FromException(new AbTransportException(AbStatus.Unsupported, refusal))
|
||||
@@ -75,7 +76,7 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
|
||||
/// libplctag name <see cref="LibplctagLegacyName"/> had to change (<see cref="LibplctagLegacyName.IsTranslated"/>)
|
||||
/// — file-number-less <c>S:</c>/<c>O:</c>/<c>I:</c> (incl. bit writes such as <c>O:0/3</c>, refused before the
|
||||
/// parent-word read-modify-write) and the status bits rewritten to control word + bit (<c>R6:0.DN</c>, counter
|
||||
/// <c>UA</c>). Before #659 libplctag could not create those tags at all, so no write to one ever reached the
|
||||
/// <c>UA</c>). Previously libplctag could not create those tags at all, so no write to one ever reached the
|
||||
/// controller; the read fix must not widen the write surface of the default transport. T/C status bits
|
||||
/// libplctag parses itself (<c>T4:0.DN</c>, <c>C5:0.CU</c>…) keep their pre-existing behaviour — a masked bit
|
||||
/// write (documented hazard in <c>docs/drivers/AbLegacy.md</c>).
|
||||
|
||||
@@ -9,5 +9,6 @@ internal static class LibplctagPath
|
||||
{
|
||||
/// <summary>Returns <see langword="null"/> for a null, empty or whitespace path; otherwise the path unchanged.</summary>
|
||||
/// <param name="path">The parsed CIP path.</param>
|
||||
/// <returns>The path, or <see langword="null"/> when it is null, empty or whitespace.</returns>
|
||||
public static string? Normalize(string? path) => string.IsNullOrWhiteSpace(path) ? null : path;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,8 @@ internal sealed class NativeAbLegacyTagRuntime : IAbLegacyTagRuntime
|
||||
private int _status;
|
||||
|
||||
/// <summary>Creates a runtime that leases the shared connection for its device from <paramref name="pool"/>.</summary>
|
||||
/// <param name="p">The tag creation parameters.</param>
|
||||
/// <param name="pool">The device session pool that supplies the shared connection.</param>
|
||||
public NativeAbLegacyTagRuntime(AbLegacyTagCreateParams p, DeviceSessionPool pool)
|
||||
: this(p, pool, OptionsFor(p))
|
||||
{
|
||||
@@ -54,6 +56,10 @@ internal sealed class NativeAbLegacyTagRuntime : IAbLegacyTagRuntime
|
||||
}
|
||||
|
||||
/// <summary>Test seam: runs over an explicit scheduler with an explicit requestor identity.</summary>
|
||||
/// <param name="p">The tag creation parameters.</param>
|
||||
/// <param name="scheduler">The request scheduler to run over.</param>
|
||||
/// <param name="vendor">The requestor vendor id.</param>
|
||||
/// <param name="serial">The requestor serial number.</param>
|
||||
internal NativeAbLegacyTagRuntime(AbLegacyTagCreateParams p, RequestScheduler scheduler, ushort vendor, uint serial)
|
||||
{
|
||||
_plc = p.LibplctagPlcAttribute;
|
||||
@@ -92,15 +98,23 @@ internal sealed class NativeAbLegacyTagRuntime : IAbLegacyTagRuntime
|
||||
/// 0x68/0x67 for LGX_PCCC, while this runtime sends SLC typed 0xA2/0xAA (no capture exists either way).
|
||||
/// plc5 is likewise unverified beyond N-file reads. Neither family should default to Native until proven.
|
||||
/// </remarks>
|
||||
/// <param name="plcAttribute">The libplctag PLC attribute (for example <c>logixpccc</c>).</param>
|
||||
/// <returns>The CIP routing mode for that PLC attribute.</returns>
|
||||
internal static CipRouting RoutingFor(string plcAttribute) => plcAttribute switch
|
||||
{
|
||||
"logixpccc" => CipRouting.UnconnectedSend,
|
||||
_ => CipRouting.Direct,
|
||||
};
|
||||
|
||||
/// <summary>Builds the shared-session key for the tag's device.</summary>
|
||||
/// <param name="p">The tag creation parameters.</param>
|
||||
/// <returns>The device key.</returns>
|
||||
internal static DeviceKey KeyFor(AbLegacyTagCreateParams p) =>
|
||||
new(p.Gateway, p.Port > 0 ? p.Port : DefaultPort, p.CipPath, p.LibplctagPlcAttribute);
|
||||
|
||||
/// <summary>Builds the connection options for the tag's device.</summary>
|
||||
/// <param name="p">The tag creation parameters.</param>
|
||||
/// <returns>The connection options.</returns>
|
||||
internal static ConnectionOptions OptionsFor(AbLegacyTagCreateParams p)
|
||||
{
|
||||
var timeout = p.Timeout > TimeSpan.Zero && p.Timeout != Timeout.InfiniteTimeSpan
|
||||
@@ -119,7 +133,7 @@ internal sealed class NativeAbLegacyTagRuntime : IAbLegacyTagRuntime
|
||||
/// The single authority for the writes this runtime refuses before the wire, or <see langword="null"/>
|
||||
/// when it attempts the write. <see cref="WriteCoreAsync"/> throws on it, and the driver asks it (via
|
||||
/// <see cref="NativeAbLegacyTagFactory.WriteRefusal"/>) before creating a runtime, so a refused write never
|
||||
/// costs the init read either (Gitea #593 follow-up).
|
||||
/// costs the init read either.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para><b>PLC-5:</b> every write — no capture of the PLC-5 write frames exists.</para>
|
||||
@@ -230,7 +244,7 @@ internal sealed class NativeAbLegacyTagRuntime : IAbLegacyTagRuntime
|
||||
}
|
||||
|
||||
/// <summary>Bytes of an Execute PCCC reply beyond its data: CIP header, requestor id, PCCC command / status / TNS
|
||||
/// (rounded up) — the scheduler budgets <c>PcccReplyOverhead + data</c> against the connection (#582).</summary>
|
||||
/// (rounded up) — the scheduler budgets <c>PcccReplyOverhead + data</c> against the connection.</summary>
|
||||
private const int PcccReplyOverhead = 32;
|
||||
|
||||
private async Task<ReadOnlyMemory<byte>> ExchangeAsync(Func<ushort, byte[]> pccc, CancellationToken ct, int replyDataBytes = 0)
|
||||
@@ -438,7 +452,7 @@ internal sealed class NativeAbLegacyTagRuntime : IAbLegacyTagRuntime
|
||||
/// Releases the lease on the shared device connection (the last release closes it) without waiting:
|
||||
/// closing does I/O (Forward Close / UnRegisterSession) and the driver disposes under a per-tag lock.
|
||||
/// The same release shape as the native AbCip runtime — see <see cref="ConnectionLease.ReleaseInBackground"/>
|
||||
/// for why a re-create the next instant still cannot overlap the close (Gitea #579).
|
||||
/// for why a re-create the next instant still cannot overlap the close.
|
||||
/// </summary>
|
||||
public void Dispose() => _lease?.ReleaseInBackground();
|
||||
}
|
||||
|
||||
+2
-2
@@ -80,8 +80,8 @@ public static class CalculationTagDefinitionFactory
|
||||
/// The field-by-field read, over an already-parsed root. Split out of
|
||||
/// <see cref="TryReadTagConfig"/> so <see cref="Inspect"/> — which has necessarily parsed the blob
|
||||
/// already to reach its root-kind and present-but-discarded checks — can consult the one authority
|
||||
/// on what a usable <c>scriptId</c> is without parsing the same string a second time
|
||||
/// (Driver.Calculation.Contracts-001). The public string-based entry point is what other modules
|
||||
/// on what a usable <c>scriptId</c> is without parsing the same string a second time.
|
||||
/// The public string-based entry point is what other modules
|
||||
/// consume, so it keeps its signature.
|
||||
/// </summary>
|
||||
/// <param name="root">The parsed <c>TagConfig</c> root element.</param>
|
||||
|
||||
@@ -28,7 +28,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
|
||||
/// at least once, and equal results are deduped) and/or on a timer (one timer per interval group).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Where scripts run (Driver.Calculation-005).</b> <see cref="OnDependencyValue"/> is called
|
||||
/// <b>Where scripts run.</b> <see cref="OnDependencyValue"/> is called
|
||||
/// directly and synchronously by <c>DependencyConsumerMuxAdapter</c> — on an Akka actor's own
|
||||
/// dispatcher thread — so it does no evaluation itself: it records the value, <i>prepares</i> the
|
||||
/// affected tags (definition + dependency snapshot, taken at trigger time) and hands them to a
|
||||
@@ -47,7 +47,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
|
||||
/// </summary>
|
||||
public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscribable, IReadable, ISkippedTagReporter, IDisposable
|
||||
{
|
||||
/// <summary>Authored tags this driver could not build into its calc table (Gitea #541). A raw tag with
|
||||
/// <summary>Authored tags this driver could not build into its calc table. A raw tag with
|
||||
/// no <c>scriptId</c> is not a calc tag and computes nothing.</summary>
|
||||
private readonly SkippedTagLedger _skippedTags = new();
|
||||
|
||||
@@ -79,10 +79,9 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
|
||||
|
||||
// Live evaluation state. Guarded by _lock: OnDependencyValue (the mux actor's thread), timer ticks
|
||||
// and the evaluation pump (pool threads) all touch it, so state mutation is serialized. _lock is a
|
||||
// SHORT-HELD state lock — it is taken for dictionary work only and is NEVER held across a script run
|
||||
// (Driver.Calculation-005).
|
||||
// SHORT-HELD state lock — it is taken for dictionary work only and is NEVER held across a script run.
|
||||
private readonly object _lock = new();
|
||||
// Dependency SNAPSHOTS, not bare values (archreview 05/STAB-19): the status code is what tells a
|
||||
// Dependency SNAPSHOTS, not bare values: the status code is what tells a
|
||||
// comms-lost input apart from a genuine Good null, and storing values only is what let this driver
|
||||
// compute a Good output from a dead source.
|
||||
private readonly Dictionary<string, DataValueSnapshot> _depValues = new(StringComparer.Ordinal);
|
||||
@@ -273,7 +272,9 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
|
||||
/// <c>CalculationDriverConfigDto</c>.</summary>
|
||||
private sealed class ReinitConfigDto
|
||||
{
|
||||
/// <summary>The raw tag entries carried by the re-initialisation config.</summary>
|
||||
public List<RawTagEntry>? RawTags { get; init; }
|
||||
/// <summary>The optional evaluation timeout in milliseconds; ignored on re-initialisation.</summary>
|
||||
public int? RunTimeoutMs { get; init; }
|
||||
}
|
||||
|
||||
@@ -285,14 +286,12 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
|
||||
/// <inheritdoc />
|
||||
public string DriverType => DriverTypeNames.Calculation;
|
||||
|
||||
// InitializeAsync takes a config document and the IDriver contract is that the driver serves it
|
||||
// (the runtime's mid-connect adoption path was deleted) - so it adopts a config carrying a real
|
||||
// body (the convention every other driver follows), and disposes any running timer scheduler before
|
||||
// arming a fresh one (re-initialise without teardown must not leak). A "{}" / empty document keeps
|
||||
// the constructor-supplied tags, as unit tests rely on.
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// #664: <c>InitializeAsync</c> takes a config document and the <see cref="IDriver"/> contract is that the
|
||||
/// driver serves it (the runtime's mid-connect adoption path was deleted in #665) — so it adopts a config carrying a
|
||||
/// real body (the #516 convention every other driver follows), and disposes any running timer scheduler
|
||||
/// before arming a fresh one (#630: re-initialise without teardown must not leak). A <c>"{}"</c> / empty
|
||||
/// document keeps the constructor-supplied tags, as unit tests rely on.
|
||||
/// </remarks>
|
||||
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
StopTimers();
|
||||
@@ -310,7 +309,7 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
|
||||
{
|
||||
// An in-place config change: re-bind the new config and REBUILD the tag table + dependency-ref set, so
|
||||
// added tags compute, removed tags stop publishing and edited scripts serve the new result. The runtime
|
||||
// itself applies a DriverConfig change by respawning (#516/#665), where a fresh driver registers its
|
||||
// itself applies a DriverConfig change by respawning, where a fresh driver registers its
|
||||
// new dependency refs on construction; this path keeps the IDriver contract honest for any caller.
|
||||
StopTimers();
|
||||
AdoptConfig(driverConfigJson);
|
||||
@@ -330,19 +329,15 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Current health, derived from the driver's own lifecycle state.
|
||||
/// <para><b>Gitea #517.</b> This used to return a hard-coded <see cref="DriverState.Healthy"/> that
|
||||
/// consulted nothing. That made it the fleet's one driver whose <c>GetHealth()</c> reported Healthy
|
||||
/// <em>with its timers stopped</em> — not as a race window, but permanently and deterministically:
|
||||
/// before <see cref="InitializeAsync"/>, after <see cref="ShutdownAsync"/>, and after
|
||||
/// <see cref="Dispose"/>. Every other driver reads a <c>_health</c> field that shutdown lands on
|
||||
/// <see cref="DriverState.Unknown"/>.</para>
|
||||
/// <para>There is no transport to be Degraded about — this driver computes from the dependency mux,
|
||||
/// so its only meaningful states are "running" and "not running". <c>_running</c> is the flag the
|
||||
/// compute paths themselves gate on, which is what makes it the honest input here.</para>
|
||||
/// </summary>
|
||||
/// <returns>Healthy while running; otherwise Unknown, retaining the last compute time.</returns>
|
||||
// Current health, derived from the driver's own lifecycle state: Healthy while running; otherwise
|
||||
// Unknown, retaining the last compute time. Health is never a hard-coded Healthy - a driver whose
|
||||
// timers are stopped (before InitializeAsync, after ShutdownAsync, after Dispose) must not report
|
||||
// Healthy, matching every other driver, whose _health field lands on Unknown at shutdown.
|
||||
//
|
||||
// There is no transport to be Degraded about - this driver computes from the dependency mux, so its
|
||||
// only meaningful states are "running" and "not running". _running is the flag the compute paths
|
||||
// themselves gate on, which is what makes it the honest input here.
|
||||
/// <inheritdoc />
|
||||
public DriverHealth GetHealth() => _running
|
||||
? new DriverHealth(DriverState.Healthy, _lastComputeUtc, null)
|
||||
: new DriverHealth(DriverState.Unknown, _lastComputeUtc, null);
|
||||
@@ -363,7 +358,7 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
|
||||
_arrivedDeps.Add(rawPath);
|
||||
if (!_changeDependents.TryGetValue(rawPath, out var dependents) || dependents.Count == 0) return;
|
||||
|
||||
// QUEUE each change-triggered dependent — never evaluate here (Driver.Calculation-005). This
|
||||
// QUEUE each change-triggered dependent — never evaluate here. This
|
||||
// runs on the caller's thread, which for the live path is the Akka dispatcher thread of
|
||||
// Runtime's DependencyConsumerMuxAdapter, and a script run is bounded only by RunTimeout.
|
||||
// Everything under this lock is dictionary work; the script runs on the pump.
|
||||
@@ -426,7 +421,7 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
|
||||
return new DataValueSnapshot(null, StatusBadWaitingForInitialData, null, now);
|
||||
// Exactly what the subscriber was last told — including a propagated input sub-code. Deriving
|
||||
// it from a two-state flag here would make a read disagree with the subscription over the
|
||||
// same node (05/STAB-19).
|
||||
// same node.
|
||||
var status = st.LastPublishedStatus;
|
||||
return new DataValueSnapshot(st.LastValue, status, st.LastTimestampUtc, st.LastTimestampUtc);
|
||||
}
|
||||
@@ -484,7 +479,7 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
|
||||
if (_depValues.TryGetValue(dep, out var v)) deps[dep] = v;
|
||||
|
||||
// Worst-of-input, computed from the SAME snapshot the evaluation will use, so the status
|
||||
// published can never disagree with the values it was derived from (05/STAB-19).
|
||||
// published can never disagree with the values it was derived from.
|
||||
var worst = InputQuality.WorstOf(def.DependencyRefs.Select(r => deps.GetValueOrDefault(r)));
|
||||
return new PendingEvaluation(def, deps, timestampUtc, worst);
|
||||
}
|
||||
@@ -556,7 +551,7 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
|
||||
{
|
||||
var def = pending.Definition;
|
||||
|
||||
// HOLD ON BAD (05/STAB-19, matching ScriptedAlarmEngine's AreInputsReady): a Bad input is not
|
||||
// HOLD ON BAD (matching ScriptedAlarmEngine's AreInputsReady): a Bad input is not
|
||||
// usable, so the script does NOT run — which removes the "(double)ctx.GetTag(x).Value threw"
|
||||
// storm at its source rather than catching it downstream — and the tag reports the input's own
|
||||
// sub-code. Uncertain IS usable: the script runs and the output inherits the Uncertain code.
|
||||
@@ -576,7 +571,7 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
|
||||
}
|
||||
|
||||
// Snapshots, not bare values: the script's ctx.GetTag(path).StatusCode / .SourceTimestampUtc are
|
||||
// then the input's real ones rather than a fabricated Good stamped at evaluation time (05/STAB-19).
|
||||
// then the input's real ones rather than a fabricated Good stamped at evaluation time.
|
||||
var result = _evaluator.Evaluate(def.RawPath, def.ScriptSource, pending.Dependencies);
|
||||
|
||||
string? failureReason = null;
|
||||
@@ -604,7 +599,7 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
|
||||
|
||||
/// <summary>Record a successful evaluation. MUST be called under <see cref="_lock"/>; returns what to
|
||||
/// publish, or null when the result is deduped.
|
||||
/// <para><b>The dedup key is (value, status), not value alone</b> — 05/STAB-19 generalising the
|
||||
/// <para><b>The dedup key is (value, status), not value alone</b> — generalising the
|
||||
/// Bad-recovery bypass 02/S13 introduced. Recovery is a publish, never an assumption: the node is
|
||||
/// currently carrying the previous status, so a result whose VALUE is unchanged but whose STATUS
|
||||
/// differs — the shape of every recovery, since a device that comes back usually comes back holding
|
||||
@@ -629,7 +624,7 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
|
||||
return (value, statusCode);
|
||||
}
|
||||
|
||||
/// <summary>Record that this tag's INPUTS are Bad, so no evaluation ran (05/STAB-19). Publishes the
|
||||
/// <summary>Record that this tag's INPUTS are Bad, so no evaluation ran. Publishes the
|
||||
/// worst input's status — carrying the last known value — <b>once per change of that status</b>: a
|
||||
/// repeat of the same Bad is silent, while <c>BadNoCommunication</c> arriving over an earlier
|
||||
/// <c>BadInternalError</c> (or the reverse) is not masked, because the sub-code is what names which
|
||||
@@ -720,7 +715,7 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
|
||||
// always carries a non-null handle.
|
||||
private static readonly CalculationSubscriptionHandle SharedHandle = new("calc-shared");
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Stops the timers and disposes the script evaluator.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
@@ -742,7 +737,7 @@ public sealed class CalculationDriver : IDriver, IDependencyConsumer, ISubscriba
|
||||
public bool HasPublished;
|
||||
|
||||
/// <summary>The status code last published for this tag. Replaces the old two-state
|
||||
/// <c>LastPublishedBad</c> flag (05/STAB-19): the dedup key is (value, status), and a Bad status
|
||||
/// <c>LastPublishedBad</c> flag: the dedup key is (value, status), and a Bad status
|
||||
/// now carries the failing INPUT's sub-code rather than a fixed <c>BadInternalError</c>.</summary>
|
||||
public uint LastPublishedStatus;
|
||||
public DateTime LastTimestampUtc;
|
||||
|
||||
@@ -8,7 +8,7 @@ using SerilogLogger = Serilog.ILogger;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
|
||||
|
||||
/// <summary>
|
||||
/// T0-4 — the Calculation driver's script evaluator. A calc tag is a raw tag whose value is
|
||||
/// The Calculation driver's script evaluator. A calc tag is a raw tag whose value is
|
||||
/// produced by a C# script over other tags' live values. This evaluator is a deliberate mirror of
|
||||
/// the VirtualTag <c>RoslynVirtualTagEvaluator</c>: it compiles each unique source once via
|
||||
/// <see cref="ScriptEvaluator{TContext, TResult}"/> (the Roslyn-backed sandbox), caches the
|
||||
@@ -25,14 +25,14 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation;
|
||||
/// eval engine, exactly as in the VirtualTag adapter.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Error surfacing (for WP7).</b> This evaluator never throws for script faults: compile error,
|
||||
/// <b>Error surfacing .</b> This evaluator never throws for script faults: compile error,
|
||||
/// sandbox violation, runtime throw, and timeout all return <see cref="VirtualTagEvalResult.Failure"/>
|
||||
/// with a descriptive <c>Reason</c>. A successful run returns <see cref="VirtualTagEvalResult.Ok"/>
|
||||
/// carrying the computed value (which may be null). WP7's <c>CalculationDriver</c> maps a
|
||||
/// carrying the computed value (which may be null). The <c>CalculationDriver</c> maps a
|
||||
/// <c>Failure</c> to Bad quality + a <c>ScriptLogEntry</c> and an <c>Ok</c> to the published value.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Cache management.</b> Implements <see cref="IScriptCacheOwner"/> so WP7 can drop compiled
|
||||
/// <b>Cache management.</b> Implements <see cref="IScriptCacheOwner"/> so the Calculation driver can drop compiled
|
||||
/// scripts at each deploy generation whose source set changed — mirroring the VirtualTag adapter's
|
||||
/// apply-boundary clear, which is what stops collectible <c>AssemblyLoadContext</c>s accreting across
|
||||
/// script edits.
|
||||
@@ -42,7 +42,7 @@ public sealed class CalculationEvaluator : IScriptCacheOwner, IDisposable
|
||||
{
|
||||
// Source-hash-keyed compile cache: Lazy single-compile (no double-compile race) and Clear()/Dispose()
|
||||
// that dispose each evaluator's collectible AssemblyLoadContext. ClearCompiledScripts() — driven by the
|
||||
// WP7 driver per deploy generation — is what stops ALCs accreting across script edits.
|
||||
// Calculation driver per deploy generation — is what stops ALCs accreting across script edits.
|
||||
private readonly CompiledScriptCache<VirtualTagContext, object?> _cache = new();
|
||||
private readonly ILogger _logger;
|
||||
private readonly SerilogLogger _scriptRoot;
|
||||
@@ -75,12 +75,13 @@ public sealed class CalculationEvaluator : IScriptCacheOwner, IDisposable
|
||||
/// <param name="calcTagId">Identity of the calc tag (bound to the script log; in the live path equals the script id).</param>
|
||||
/// <param name="scriptSource">The C# script source producing the tag's value.</param>
|
||||
/// <param name="dependencies">Read-only snapshot of dependency values keyed by RawPath.</param>
|
||||
/// <returns>The evaluation result, including any script fault.</returns>
|
||||
/// <remarks>
|
||||
/// <b>Value-only overload.</b> Every dependency reaches the script stamped <c>Good</c> with an
|
||||
/// evaluation-time timestamp, because bare values carry nothing better. Prefer
|
||||
/// <see cref="Evaluate(string, string, IReadOnlyDictionary{string, DataValueSnapshot})"/>, which the
|
||||
/// driver uses: it carries each input's real status and source timestamp, so
|
||||
/// <c>ctx.GetTag(path).StatusCode</c> is honest (archreview 05/STAB-19). This overload remains for
|
||||
/// <c>ctx.GetTag(path).StatusCode</c> is honest. This overload remains for
|
||||
/// callers that genuinely hold only values — chiefly tests.
|
||||
/// </remarks>
|
||||
public VirtualTagEvalResult Evaluate(string calcTagId, string scriptSource, IReadOnlyDictionary<string, object?> dependencies)
|
||||
@@ -102,7 +103,7 @@ public sealed class CalculationEvaluator : IScriptCacheOwner, IDisposable
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The snapshots pass <b>through</b> to the script's read cache rather than being re-wrapped as
|
||||
/// Good (archreview 05/STAB-19). The old wrapping meant a script that inspected
|
||||
/// Good. The old wrapping meant a script that inspected
|
||||
/// <c>ctx.GetTag(path).StatusCode</c> was told Good for every input, including one whose driver had
|
||||
/// stopped communicating — so a quality-aware script could not be written at all. The driver holds
|
||||
/// evaluation entirely while any input is Bad, so what a script sees here is Good or Uncertain.
|
||||
|
||||
@@ -103,6 +103,7 @@ internal sealed class CalculationTimerScheduler : IDisposable
|
||||
{
|
||||
// 0 = idle, 1 = a tick is currently running for this group.
|
||||
public int InFlight;
|
||||
/// <summary>The paths evaluated on each tick of this group.</summary>
|
||||
public IReadOnlyList<string> Paths { get; } = paths;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
/// <see cref="FocasAreaKind.Fixed"/> (a logical path into the driver's fixed tree —
|
||||
/// <c>FIXED:Identity/SeriesNumber</c>, <c>FIXED:Axes/X/AbsolutePosition</c>), plus
|
||||
/// <see cref="FocasAreaKind.Alarm"/> (a selector over the CNC's active alarm list — <c>ALARM:*</c>,
|
||||
/// <c>ALARM:Servo/401</c>, <c>ALARM:OTHER</c>; see <see cref="FocasAlarmSelector"/>, Gitea #647).
|
||||
/// <c>ALARM:Servo/401</c>, <c>ALARM:OTHER</c>; see <see cref="FocasAlarmSelector"/>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>PMC letters: <c>X/Y</c> (IO), <c>F/G</c> (signals between PMC + CNC), <c>R</c> (internal
|
||||
@@ -22,12 +22,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
/// snapshot the driver's own poll loop already maintains (<c>cnc_sysinfo</c>, <c>cnc_rddynamic2</c>,
|
||||
/// program info, timers, spindle data). It exists because the fixed tree is the only part of the
|
||||
/// FOCAS surface with no address of its own, and v3 publishes nodes only for authored tags — so
|
||||
/// without a form to author, the fixed tree could not be published at all (Gitea #540).
|
||||
/// without a form to author, the fixed tree could not be published at all.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The path carries no device.</b> A tag's device comes from its raw-tag Device row, and an
|
||||
/// address that also named a host would be a second, conflicting routing channel — which is
|
||||
/// precisely the defect #540 reported: the browse leaf's <c>{host}/{path}</c> full reference was
|
||||
/// precisely the defect once reported: the browse leaf's <c>{host}/{path}</c> full reference was
|
||||
/// written into the address field verbatim and then failed to parse, so every browse-committed
|
||||
/// fixed-tree tag read <c>BadNodeIdUnknown</c> forever.
|
||||
/// </para>
|
||||
@@ -43,7 +43,7 @@ public sealed record FocasAddress(
|
||||
/// <summary>The prefix that introduces a <see cref="FocasAreaKind.Fixed"/> address.</summary>
|
||||
public const string FixedPrefix = "FIXED:";
|
||||
|
||||
/// <summary>The prefix that introduces a <see cref="FocasAreaKind.Alarm"/> address (Gitea #647).</summary>
|
||||
/// <summary>The prefix that introduces a <see cref="FocasAreaKind.Alarm"/> address.</summary>
|
||||
public const string AlarmPrefix = "ALARM:";
|
||||
|
||||
/// <summary>Gets the canonical string representation of this address.</summary>
|
||||
@@ -113,7 +113,7 @@ public sealed record FocasAddress(
|
||||
return path is null ? null : new FocasAddress(FocasAreaKind.Fixed, null, 0, null, path);
|
||||
}
|
||||
|
||||
// Alarm selector (Gitea #647). Tested BEFORE the PMC branch for the same reason as FIXED: — "A" is a
|
||||
// Alarm selector. Tested BEFORE the PMC branch for the same reason as FIXED: — "A" is a
|
||||
// valid PMC letter (message display), so ALARM:… must be owned by this rule, not diagnosed as PMC.
|
||||
if (src.StartsWith(AlarmPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
@@ -213,7 +213,7 @@ public enum FocasAreaKind
|
||||
|
||||
/// <summary>
|
||||
/// A selector over the device's active CNC alarm list (<c>ALARM:*</c>, <c>ALARM:Servo/401-453</c>,
|
||||
/// <c>ALARM:OTHER</c>) — Gitea #647. Meaningful only on a raw tag that also carries the cross-driver
|
||||
/// <c>ALARM:OTHER</c>). Meaningful only on a raw tag that also carries the cross-driver
|
||||
/// <c>alarm</c> object, which makes it ONE Part 9 condition at its RawPath; it has no value to read
|
||||
/// and nothing to write.
|
||||
/// </summary>
|
||||
|
||||
@@ -2,7 +2,7 @@ using System.Globalization;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>Which CNC alarms an <c>ALARM:</c> address selects (Gitea #647).</summary>
|
||||
/// <summary>Which CNC alarms an <c>ALARM:</c> address selects.</summary>
|
||||
public enum FocasAlarmSelectorKind
|
||||
{
|
||||
/// <summary><c>ALARM:*</c> — every active alarm on the tag's device (a device summary condition).</summary>
|
||||
@@ -20,7 +20,7 @@ public enum FocasAlarmSelectorKind
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The selector half of an <c>ALARM:</c> FOCAS address (Gitea #647): which entries of the CNC's flat,
|
||||
/// The selector half of an <c>ALARM:</c> FOCAS address: which entries of the CNC's flat,
|
||||
/// per-device active-alarm list (<c>cnc_rdalmmsg2</c>) drive the ONE Part 9 condition the raw tag is
|
||||
/// materialised as.
|
||||
/// </summary>
|
||||
|
||||
@@ -11,8 +11,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
/// matching type rather than relying on implicit int→short narrowing on the constants).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ⚠️ <b>This numbering is the 16i/18i/21i-era table, and it is the ONLY table this driver knows</b>
|
||||
/// (Gitea #647). The 30i/31i/32i series number <c>alm_type</c> differently, and that table has not
|
||||
/// ⚠️ <b>This numbering is the 16i/18i/21i-era table, and it is the ONLY table this driver knows</b>.
|
||||
/// The 30i/31i/32i series number <c>alm_type</c> differently, and that table has not
|
||||
/// been confirmed against a FOCAS reference, so it is deliberately NOT encoded here — a guessed code
|
||||
/// would silently route the wrong alarms. On a 30i-series control the names below therefore label
|
||||
/// the raw type code with its 16i meaning, which may be wrong. Every alarm message the driver
|
||||
|
||||
@@ -31,7 +31,7 @@ public sealed class FocasDriverOptions
|
||||
public FocasHandleRecycleOptions HandleRecycle { get; init; } = new();
|
||||
/// <summary>Gets the fixed tree options.</summary>
|
||||
public FocasFixedTreeOptions FixedTree { get; init; } = new();
|
||||
/// <summary>Gets the PMC range-coalescing options (Gitea #266).</summary>
|
||||
/// <summary>Gets the PMC range-coalescing options.</summary>
|
||||
public FocasPmcCoalesceOptions PmcCoalesce { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
@@ -44,7 +44,7 @@ public sealed class FocasDriverOptions
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges adjacent / near PMC reads in one poll batch into fewer <c>pmc_rdpmcrng</c> calls (Gitea #266).
|
||||
/// Merges adjacent / near PMC reads in one poll batch into fewer <c>pmc_rdpmcrng</c> calls.
|
||||
/// On by default: a batch with a single PMC tag per area is unaffected, and a coalesced read the CNC
|
||||
/// rejects falls back to per-tag reads, so enabling it cannot make a readable tag unreadable.
|
||||
/// </summary>
|
||||
@@ -128,19 +128,19 @@ public sealed class FocasHandleRecycleOptions
|
||||
|
||||
/// <summary>
|
||||
/// Controls the CNC active-alarm polling projection that surfaces FOCAS alarms via
|
||||
/// <c>IAlarmSource</c> as Part 9 conditions at <c>ALARM:</c> raw tags (Gitea #647).
|
||||
/// <c>IAlarmSource</c> as Part 9 conditions at <c>ALARM:</c> raw tags.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Authoring an <c>ALARM:</c> tag IS the opt-in.</b> The projection polls exactly the devices that own a
|
||||
/// subscribed alarm tag, so a driver with none issues no <c>cnc_rdalmmsg2</c> traffic whatever
|
||||
/// <see cref="Enabled"/> says. Before #647, <see cref="Enabled"/> gated the poll and
|
||||
/// <see cref="Enabled"/> says. Previously <see cref="Enabled"/> gated the poll and
|
||||
/// <c>SubscribeAlarmsAsync</c> THREW while it was false — so an operator who authored an alarm tag and left
|
||||
/// the default got a feed that stayed gated with a Warning on every reconnect. It is retained so existing
|
||||
/// configs still bind, and no longer has any effect.
|
||||
/// </remarks>
|
||||
public sealed class FocasAlarmProjectionOptions
|
||||
{
|
||||
/// <summary>Retained for config compatibility; has no effect since Gitea #647 (see the class remarks).</summary>
|
||||
/// <summary>Retained for config compatibility; has no effect (see the class remarks).</summary>
|
||||
public bool Enabled { get; init; } = false;
|
||||
|
||||
/// <summary>Poll cadence. One <c>cnc_rdalmmsg2</c> call per device per tick.</summary>
|
||||
@@ -204,7 +204,7 @@ public sealed record FocasDeviceOptions(
|
||||
/// clients can optimise write coalescing for idempotent tags. Defaults to <c>false</c>.
|
||||
/// </param>
|
||||
/// <param name="Alarm">
|
||||
/// The tag's cross-driver <c>alarm</c> object, when it carries one (Gitea #647) — which makes the host
|
||||
/// The tag's cross-driver <c>alarm</c> object, when it carries one — which makes the host
|
||||
/// materialise it as a Part 9 condition at its RawPath. Only an <c>ALARM:</c> address can drive one.
|
||||
/// </param>
|
||||
public sealed record FocasTagDefinition(
|
||||
@@ -217,7 +217,7 @@ public sealed record FocasTagDefinition(
|
||||
FocasTagAlarm? Alarm = null);
|
||||
|
||||
/// <summary>
|
||||
/// What the FOCAS driver reads from a tag's cross-driver <c>alarm</c> object (Gitea #647). The host owns the
|
||||
/// What the FOCAS driver reads from a tag's cross-driver <c>alarm</c> object. The host owns the
|
||||
/// rest of it (<c>alarmType</c>, <c>historizeToAveva</c>) through <c>TagConfigIntent</c>.
|
||||
/// </summary>
|
||||
/// <param name="Severity">
|
||||
|
||||
@@ -50,7 +50,7 @@ public static class FocasTagDefinitionFactory
|
||||
return false;
|
||||
var address = addr.GetString();
|
||||
if (string.IsNullOrWhiteSpace(address)) return false;
|
||||
// Strict enum read (R2-11 Phase C): a typo'd dataType rejects the tag (→ BadNodeIdUnknown)
|
||||
// Strict enum read: a typo'd dataType rejects the tag (→ BadNodeIdUnknown)
|
||||
// instead of silently defaulting to a wrong-width Good.
|
||||
if (!TagConfigJson.TryReadEnumStrict(root, "dataType", FocasDataType.Int32, out var dataType)) return false;
|
||||
// FOCAS authored tags carry no `writable` key (writability lives on the Tag entity / node ACL):
|
||||
@@ -74,7 +74,7 @@ public static class FocasTagDefinitionFactory
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The tag's cross-driver <c>alarm</c> object (Gitea #647), or <see langword="null"/> when it carries none.
|
||||
/// The tag's cross-driver <c>alarm</c> object, or <see langword="null"/> when it carries none.
|
||||
/// Lenient like the host's <c>TagConfigIntent</c>: a non-integer <c>severity</c> is simply absent.
|
||||
/// </summary>
|
||||
/// <param name="root">The TagConfig root object.</param>
|
||||
@@ -116,20 +116,20 @@ public static class FocasTagDefinitionFactory
|
||||
private const string InspectionRawPath = "(inspect)";
|
||||
|
||||
/// <summary>
|
||||
/// Deploy-time inspection (05/CONV-2, 05/UNDER-1). Two kinds of warning, and the distinction is
|
||||
/// Deploy-time inspection. Two kinds of warning, and the distinction is
|
||||
/// load-bearing:
|
||||
/// <list type="bullet">
|
||||
/// <item>
|
||||
/// <b>Rejections</b> — a blob <see cref="FromTagConfig"/> would refuse: an unparseable or
|
||||
/// non-object document, an absent / non-string / blank <c>address</c>, or a
|
||||
/// present-but-invalid <c>dataType</c> (the runtime enum read is STRICT since R2-11 Phase C,
|
||||
/// present-but-invalid <c>dataType</c> (the runtime enum read is STRICT,
|
||||
/// so a typo rejects the tag). The driver skips such a tag and it reads
|
||||
/// <c>BadNodeIdUnknown</c> forever. These are diagnosed only when the mapper has actually
|
||||
/// refused, so this gate can never be stricter than the runtime (Gitea #551).
|
||||
/// refused, so this gate can never be stricter than the runtime.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// <b>Advisories</b> — an <em>accepted</em> blob that still will not work: an unparseable
|
||||
/// FOCAS address (#540; the mapper only requires a non-blank string, the driver's own init
|
||||
/// FOCAS address (the mapper only requires a non-blank string, the driver's own init
|
||||
/// then drops it) and an authored <c>writable:true</c> (the FOCAS wire is read-only, so
|
||||
/// <c>WriteAsync</c> always returns <c>BadNotWritable</c>). These run whether or not the
|
||||
/// blob parses, which is why FOCAS does not simply return empty on accept.
|
||||
@@ -138,7 +138,7 @@ public static class FocasTagDefinitionFactory
|
||||
/// Empty for a clean, accepted blob, for a blank one, or for one that is not an equipment-tag
|
||||
/// object. Never throws.
|
||||
/// <para>
|
||||
/// The distinction is <b>structural</b>, not a matter of wording (Gitea #556 item 2): the two
|
||||
/// The distinction is <b>structural</b>, not a matter of wording: the two
|
||||
/// classes accumulate in separate lists, and the catch-all backstop — the only thing that
|
||||
/// reports a refusal neither <see cref="DescribeRejection"/> branch can name — fires on "no
|
||||
/// REJECTION was produced", never on "no warning at all". Gated on the total, an advisory
|
||||
@@ -149,7 +149,7 @@ public static class FocasTagDefinitionFactory
|
||||
/// <returns>The warnings; empty when clean.</returns>
|
||||
public static IReadOnlyList<string> Inspect(string reference)
|
||||
{
|
||||
// Gitea #556 item 2: the two classes live in SEPARATE lists, and the backstop below gates on the
|
||||
// The two classes live in SEPARATE lists, and the backstop below gates on the
|
||||
// REJECTION list alone. Sharing one list meant an advisory (which fires on an accepted blob too)
|
||||
// could satisfy a `warnings.Count == 0` guard and silently swallow the catch-all rejection message
|
||||
// — the only thing that reports a refusal neither DescribeRejection branch can name. The split is
|
||||
@@ -157,7 +157,7 @@ public static class FocasTagDefinitionFactory
|
||||
var rejections = new List<string>();
|
||||
var advisories = new List<string>();
|
||||
|
||||
// Gitea #556 item 1: the framing check TRIMS FIRST. FromTagConfig hands the blob straight to
|
||||
// The framing check TRIMS FIRST. FromTagConfig hands the blob straight to
|
||||
// JsonDocument.Parse, which tolerates leading whitespace — so an untrimmed check let " {…}" be
|
||||
// read (and possibly REFUSED) by the driver while drawing zero deploy warnings. A reference that
|
||||
// still does not start with '{' after trimming is not an equipment-tag TagConfig at all and keeps
|
||||
@@ -247,7 +247,7 @@ public static class FocasTagDefinitionFactory
|
||||
/// accepted, so they must never reach the rejection list — see <see cref="Inspect"/>'s backstop.</param>
|
||||
private static void DescribeAdvisories(JsonElement root, List<string> advisories)
|
||||
{
|
||||
// Gitea #540: an address the driver cannot parse is skipped at init and reads BadNodeIdUnknown
|
||||
// An address the driver cannot parse is skipped at init and reads BadNodeIdUnknown
|
||||
// forever, while driver health stays Healthy with zero errors. The AdminUI modal rejects it
|
||||
// outright; this covers the paths that never touch the modal (CSV import, the API).
|
||||
var address = root.TryGetProperty("address", out var addr) && addr.ValueKind == JsonValueKind.String
|
||||
@@ -265,7 +265,7 @@ public static class FocasTagDefinitionFactory
|
||||
"this tag at startup and it will read BadNodeIdUnknown.");
|
||||
}
|
||||
|
||||
// Gitea #647: an ALARM: address and the cross-driver alarm object only work together. The object is what
|
||||
// An ALARM: address and the cross-driver alarm object only work together. The object is what
|
||||
// makes the host materialise a Part 9 condition; the address is the only thing the driver can drive one
|
||||
// from. Either one alone is a tag that does nothing.
|
||||
var hasAlarmObject = root.TryGetProperty("alarm", out var alarm) && alarm.ValueKind == JsonValueKind.Object;
|
||||
|
||||
@@ -2,7 +2,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// Turns a device-reported sibling-name sequence (axes, spindles) into unique fixed-tree
|
||||
/// path segments (Gitea #548).
|
||||
/// path segments.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -27,7 +27,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
/// <para>
|
||||
/// <b>The whole collision set is suffixed, not just the second occurrence.</b> When two
|
||||
/// axes are both called <c>AA</c>, the bare segment <c>AA</c> ceases to exist — a bare name
|
||||
/// that silently means "one of the two" is the same silent-wrongness class #534 and #540
|
||||
/// that silently means "one of the two" is the same silent-wrongness class the earlier selector fixes
|
||||
/// were about. A tag authored against bare <c>AA</c> on such a machine reads Bad, never one
|
||||
/// arbitrary axis's data.
|
||||
/// </para>
|
||||
|
||||
@@ -3,7 +3,7 @@ using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>An authored <c>ALARM:</c> raw tag the driver drives a Part 9 condition for (Gitea #647).</summary>
|
||||
/// <summary>An authored <c>ALARM:</c> raw tag the driver drives a Part 9 condition for.</summary>
|
||||
/// <param name="RawPath">The tag's RawPath — the condition's ConditionId, the only key the host routes on.</param>
|
||||
/// <param name="DeviceHostAddress">The device whose alarm list the tag selects from.</param>
|
||||
/// <param name="Selector">Which alarms on that device drive the condition.</param>
|
||||
@@ -35,7 +35,7 @@ internal sealed record FocasConditionView(IReadOnlyList<FocasMatchedAlarm> Match
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Selects, per authored <c>ALARM:</c> tag, the matching entries of a device's flat CNC alarm list (Gitea #647).
|
||||
/// Selects, per authored <c>ALARM:</c> tag, the matching entries of a device's flat CNC alarm list.
|
||||
/// Pure — the projection hands it one successful read and gets back what each condition sees.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
@@ -101,7 +101,7 @@ internal static class FocasAlarmMatcher
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What the server was last TOLD about one FOCAS condition (Gitea #647) — the diff baseline that keeps a
|
||||
/// What the server was last TOLD about one FOCAS condition — the diff baseline that keeps a
|
||||
/// reconnect, a re-subscribe or a reordered list from re-raising (and so un-acknowledging) an alarm.
|
||||
/// </summary>
|
||||
/// <param name="Active">Whether the condition was last told active.</param>
|
||||
@@ -119,7 +119,7 @@ internal sealed record FocasToldCondition(
|
||||
int? OpcUaSeverity);
|
||||
|
||||
/// <summary>
|
||||
/// The FOCAS alarm list → OPC UA Part 9 transition, as one pure function (Gitea #647): given what the server was
|
||||
/// The FOCAS alarm list → OPC UA Part 9 transition, as one pure function: given what the server was
|
||||
/// last told about a condition and what the CNC says now, the single <see cref="AlarmEventArgs"/> to raise — or
|
||||
/// none — and what the server has been told afterwards.
|
||||
/// </summary>
|
||||
@@ -130,7 +130,7 @@ internal sealed record FocasToldCondition(
|
||||
/// <item><term>active → a key not in the told set appears</term><description><see cref="AlarmTransitionKind.Retrigger"/>
|
||||
/// — a new CNC alarm number is a statement the operator has not seen, so it un-acknowledges</description></item>
|
||||
/// <item><term>active → the displayed alarm left, others remain</term><description><see cref="AlarmTransitionKind.Retrigger"/>
|
||||
/// with the next one down (the MTConnect #626 precedent: a different statement the operator has not seen)</description></item>
|
||||
/// with the next one down (the MTConnect precedent: a different statement the operator has not seen)</description></item>
|
||||
/// <item><term>active → a NON-displayed member left</term><description>nothing (no message-only events)</description></item>
|
||||
/// <item><term>active → nothing matches</term><description><see cref="AlarmTransitionKind.Clear"/></description></item>
|
||||
/// <item><term>unchanged</term><description>nothing</description></item>
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
/// <summary>
|
||||
/// Polls the CNC active-alarm list (<c>cnc_rdalmmsg2</c>) of every device that owns a subscribed
|
||||
/// <c>ALARM:</c> raw tag and drives ONE Part 9 condition per such tag (Gitea #647).
|
||||
/// <c>ALARM:</c> raw tag and drives ONE Part 9 condition per such tag.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -15,21 +15,21 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
/// arrive through <see cref="SubscribeAsync"/>. Every event uses the RawPath as
|
||||
/// <see cref="AlarmEventArgs.ConditionId"/> — the exact key the host routes on — and carries a
|
||||
/// <see cref="AlarmEventArgs.Kind"/>, without which the host's projector keeps the prior state. Before
|
||||
/// #647 this class treated the RawPaths as device-host filters (so nothing was ever polled) and raised
|
||||
/// alarm selectors were supported this class treated the RawPaths as device-host filters (so nothing was ever polled) and raised
|
||||
/// <c>"{host}#{Type}:{Number}"</c> with no kind (so nothing would have routed anyway).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A failed read is not an empty list.</b> <see cref="IFocasClient.ReadAlarmsAsync"/> throws on any
|
||||
/// failure; the device is then skipped for that tick and every condition on it keeps its state. The
|
||||
/// host already annotates the driver's conditions <c>BadNoCommunication</c> on comms loss (#477).
|
||||
/// host already annotates the driver's conditions <c>BadNoCommunication</c> on comms loss.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Delivery is diffed against what the server was TOLD</b> (<see cref="_told"/>, the MTConnect #626
|
||||
/// <b>Delivery is diffed against what the server was TOLD</b> (<see cref="_told"/>, the MTConnect
|
||||
/// pattern), recorded only while an <c>OnAlarmEvent</c> handler is attached. The record lives on this
|
||||
/// object, which lives as long as the driver: it survives a re-subscribe with the same set (a reconnect),
|
||||
/// a re-initialise and a shutdown/re-init cycle — the server's condition nodes outlive all three, and
|
||||
/// re-raising there would un-acknowledge an acknowledged alarm. A changed subscribed set prunes only the
|
||||
/// RawPaths that LEFT it (the server carries every survivor across the rebuild, #641).
|
||||
/// RawPaths that LEFT it (the server carries every survivor across the rebuild).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>One poll loop</b>, owned by the current subscription. Evaluation and delivery run under
|
||||
@@ -77,11 +77,11 @@ internal sealed class FocasAlarmProjection : IAsyncDisposable
|
||||
private const uint QualityBadNoCommunication = 0x80310000u;
|
||||
|
||||
/// <summary>
|
||||
/// Whether an alarm-read failure is a CNC REFUSAL (Gitea #647 re-review R2): the CNC answered with a non-OK
|
||||
/// Whether an alarm-read failure is a CNC REFUSAL: the CNC answered with a non-OK
|
||||
/// FOCAS return code, so the transport is fine and the control simply will not serve <c>cnc_rdalmmsg2</c>
|
||||
/// (e.g. <c>EW_FUNC</c>, as <c>cnc_rdparam</c> does on the Makino). That is a capability answer, not comms
|
||||
/// loss: it must NOT degrade driver health, or an alarm-only driver on such a control would be reconnected by
|
||||
/// the #527 watchdog every 90 s forever. Everything else (session down, wire fault, malformed reply, backoff)
|
||||
/// the comms-loss watchdog every 90 s forever. Everything else (session down, wire fault, malformed reply, backoff)
|
||||
/// is a transport failure.
|
||||
/// </summary>
|
||||
/// <param name="error">The alarm-read failure.</param>
|
||||
@@ -125,9 +125,9 @@ internal sealed class FocasAlarmProjection : IAsyncDisposable
|
||||
var sub = new Subscription(new FocasAlarmSubscriptionHandle(Interlocked.Increment(ref _nextId)), refs);
|
||||
lock (_alarmLock)
|
||||
{
|
||||
// Forget only the conditions that LEFT the set (#641) — the SAME set (a reconnect) prunes nothing.
|
||||
// Forget only the conditions that LEFT the set — the SAME set (a reconnect) prunes nothing.
|
||||
foreach (var gone in _told.Keys.Where(k => !refs.Contains(k)).ToList()) _told.Remove(gone);
|
||||
// A (re)subscribe follows a reconnect, where the host re-annotates every condition Good (#477); forget
|
||||
// A (re)subscribe follows a reconnect, where the host re-annotates every condition Good; forget
|
||||
// which conditions this projection marked Bad, so a still-failing device re-asserts its Bad.
|
||||
_qualityBadTold.Clear();
|
||||
Volatile.Write(ref _current, sub);
|
||||
@@ -173,7 +173,8 @@ internal sealed class FocasAlarmProjection : IAsyncDisposable
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
public Task ResumeAsync() => Volatile.Read(ref _current) is null ? Task.CompletedTask : RestartLoopAsync();
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Stops the poll loop and releases the projection.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async ValueTask DisposeAsync() => await StopLoopAsync().ConfigureAwait(false);
|
||||
|
||||
private async Task RestartLoopAsync()
|
||||
@@ -328,8 +329,8 @@ internal sealed class FocasAlarmProjection : IAsyncDisposable
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A FAILED read of <paramref name="deviceHostAddress"/> (Gitea #647 review): conditions keep their state, but
|
||||
/// each subscribed one is annotated once per outage through the #626 quality-only path (no Active/Acked change,
|
||||
/// A FAILED read of <paramref name="deviceHostAddress"/>: conditions keep their state, but
|
||||
/// each subscribed one is annotated once per outage through the quality-only path (no Active/Acked change,
|
||||
/// no <c>/alerts</c> row) — <c>BadNoCommunication</c> for a transport failure, <c>BadNotSupported</c> for a CNC
|
||||
/// refusal (R2, <see cref="IsCncRefusal"/>) — and the first failure of the episode (per kind) logs one
|
||||
/// Warning. The next successful read lifts the Bad in <see cref="Tick"/>.
|
||||
@@ -379,7 +380,7 @@ internal sealed class FocasAlarmProjection : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A quality-only annotation (#626): <see cref="AlarmTransitionKind.Unspecified"/> + a status code.</summary>
|
||||
/// <summary>A quality-only annotation: <see cref="AlarmTransitionKind.Unspecified"/> + a status code.</summary>
|
||||
private AlarmEventArgs QualityOnly(FocasAlarmTag tag, IAlarmSubscriptionHandle handle, uint quality, DateTime now)
|
||||
{
|
||||
_told.TryGetValue(tag.RawPath, out var told);
|
||||
|
||||
@@ -40,7 +40,7 @@ public static class FocasCapabilityMatrix
|
||||
// per subtree; a path this machine does not have resolves to BadNodeIdUnknown at read.
|
||||
FocasAreaKind.Fixed => null,
|
||||
// An ALARM: selector reads the device's whole active-alarm list (cnc_rdalmmsg2), which every
|
||||
// series exposes; the selector filters it driver-side, so there is no range to check (#647).
|
||||
// series exposes; the selector filters it driver-side, so there is no range to check.
|
||||
FocasAreaKind.Alarm => null,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
@@ -22,15 +22,14 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery, IBatchSubscribable,
|
||||
IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, ISkippedTagReporter, IDisposable, IAsyncDisposable
|
||||
{
|
||||
/// <summary>Authored tags this driver could not build into its poll table (Gitea #541). FOCAS has three
|
||||
/// skip paths — an unmappable TagConfig, an unparseable address, and a capability-matrix rejection — and
|
||||
/// #540 was caused by the second of them going unreported while the driver read Healthy.</summary>
|
||||
/// <summary>Authored tags this driver could not build into its poll table. FOCAS has three
|
||||
/// skip paths — an unmappable TagConfig, an unparseable address, and a capability-matrix rejection — and the second of them once went unreported while the driver read Healthy.</summary>
|
||||
private readonly SkippedTagLedger _skippedTags = new();
|
||||
|
||||
/// <inheritdoc />
|
||||
public SkippedTagReport? GetSkippedTags() => _skippedTags.Snapshot();
|
||||
|
||||
/// <summary>Mutable so <see cref="InitializeAsync"/> can adopt a re-parsed config (#516). Written only
|
||||
/// <summary>Mutable so <see cref="InitializeAsync"/> can adopt a re-parsed config. Written only
|
||||
/// on the init path, together with <see cref="_clientFactory"/>, before either is used.</summary>
|
||||
private FocasDriverOptions _options;
|
||||
private readonly string _driverInstanceId;
|
||||
@@ -47,7 +46,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
private readonly Dictionary<string, DeviceState> _devices = new(StringComparer.OrdinalIgnoreCase);
|
||||
// v3: RawPath → definition, case-sensitive ordinal. Built in InitializeAsync from _options.RawTags
|
||||
// via the pure FocasTagDefinitionFactory mapper (the reference is always a RawPath).
|
||||
// Built whole in InitializeAsync and SWAPPED in (Gitea #647 review): the #630-retained poll engine keeps calling
|
||||
// Built whole in InitializeAsync and SWAPPED in: the retained poll engine keeps calling
|
||||
// ReadAsync during a re-initialise, and a table cleared and refilled in place reads BadNodeIdUnknown in between.
|
||||
private Dictionary<string, FocasTagDefinition> _tagsByRawPath = new(StringComparer.Ordinal);
|
||||
// Resolves a read/write fullReference (always a RawPath in v3) to a tag definition by a single
|
||||
@@ -76,14 +75,14 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// </remarks>
|
||||
private bool _fixedTreeActive;
|
||||
/// <summary>
|
||||
/// The alarm projection (Gitea #647). Lives as long as the driver — NOT re-created per initialise — because
|
||||
/// The alarm projection. Lives as long as the driver — NOT re-created per initialise — because
|
||||
/// it holds what the server was last told about every condition, and the server's condition nodes outlive
|
||||
/// a re-initialise; re-creating it would re-raise (and so un-acknowledge) every active alarm.
|
||||
/// </summary>
|
||||
private readonly FocasAlarmProjection _alarmProjection;
|
||||
|
||||
/// <summary>
|
||||
/// RawPath → authored <c>ALARM:</c> tag (Gitea #647). Built in <see cref="InitializeAsync"/> and swapped in
|
||||
/// RawPath → authored <c>ALARM:</c> tag. Built in <see cref="InitializeAsync"/> and swapped in
|
||||
/// whole, so the alarm poll loop always sees one consistent table. Alarm tags never enter
|
||||
/// <see cref="_tagsByRawPath"/>: they have no value to read.
|
||||
/// </summary>
|
||||
@@ -156,11 +155,11 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
return trimmed is not "{}" and not "[]";
|
||||
}
|
||||
|
||||
/// <summary>Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide (05/STAB-8).</summary>
|
||||
/// <summary>Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide.</summary>
|
||||
private static readonly TimeSpan PollBackoffCap = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// 05/STAB-9 — routes a poll-loop reader failure to the driver health surface: logs it and
|
||||
/// Routes a poll-loop reader failure to the driver health surface: logs it and
|
||||
/// degrades to <see cref="DriverState.Degraded"/> preserving <c>LastSuccessfulRead</c>.
|
||||
/// Never downgrades a <see cref="DriverState.Faulted"/> state (a stronger, config-level signal).
|
||||
/// </summary>
|
||||
@@ -179,24 +178,22 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// <inheritdoc />
|
||||
public string DriverType => "FOCAS";
|
||||
|
||||
/// <summary>
|
||||
/// Opens the configured CNC handles and builds the authored tag table.
|
||||
/// <para><paramref name="driverConfigJson"/> <b>is</b> re-parsed here when the driver was built by
|
||||
/// the factory (Gitea #516), and the re-parse is <b>all-or-nothing</b>: the typed options and the
|
||||
/// <c>IFocasClientFactory</c> the <c>Backend</c> key selects come from one <c>ParseBinding</c> call
|
||||
/// and are adopted together. That pairing is the point — adopting options alone would poll a NEW
|
||||
/// device/tag set through the OLD backend, which is why this driver was excluded from the first
|
||||
/// #516 pass.</para>
|
||||
/// <para>A driver constructed directly (every unit test, or a caller supplying its own backend) gets
|
||||
/// no rebinder and keeps its constructor-supplied pair. <c>DriverSpawnPlanner</c>'s stop + respawn
|
||||
/// remains the outer guarantee.</para>
|
||||
/// </summary>
|
||||
/// <param name="driverConfigJson">The driver configuration JSON (see remarks — not re-parsed).</param>
|
||||
/// <param name="cancellationToken">Cancellation token for the operation.</param>
|
||||
/// <returns>A completed task.</returns>
|
||||
// <remarks>
|
||||
// Opens the configured CNC handles and builds the authored tag table.
|
||||
// <para><paramref name="driverConfigJson"/> <b>is</b> re-parsed here when the driver was built by
|
||||
// the factory, and the re-parse is <b>all-or-nothing</b>: the typed options and the
|
||||
// <c>IFocasClientFactory</c> the <c>Backend</c> key selects come from one <c>ParseBinding</c> call
|
||||
// and are adopted together. That pairing is the point — adopting options alone would poll a NEW
|
||||
// device/tag set through the OLD backend, which is why this driver was excluded from the first
|
||||
// respawn pass.</para>
|
||||
// <para>A driver constructed directly (every unit test, or a caller supplying its own backend) gets
|
||||
// no rebinder and keeps its constructor-supplied pair. <c>DriverSpawnPlanner</c>'s stop + respawn
|
||||
// remains the outer guarantee.</para>
|
||||
// </remarks>
|
||||
/// <inheritdoc />
|
||||
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
// #630: release the previous device states' loops + clients BEFORE they are overwritten (a live driver can be
|
||||
// release the previous device states' loops + clients BEFORE they are overwritten (a live driver can be
|
||||
// re-initialised without a teardown first). Not ShutdownAsync: that also drops _poll
|
||||
// and subscriptions, which are owned by the shutdown path (the actor re-subscribes after every successful init).
|
||||
await ReleaseDeviceStatesAsync().ConfigureAwait(false);
|
||||
@@ -204,7 +201,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
Volatile.Write(ref _health, new DriverHealth(DriverState.Initializing, null, null));
|
||||
try
|
||||
{
|
||||
// #516: adopt a changed config. Options and the backend client factory are re-derived by ONE
|
||||
// adopt a changed config. Options and the backend client factory are re-derived by ONE
|
||||
// parse and assigned together — a partial adoption would poll a new device/tag set through the
|
||||
// old backend. A null rebinder (direct construction) or an empty/placeholder document keeps the
|
||||
// constructor-supplied pair, which is what every lifecycle test passing "{}" relies on.
|
||||
@@ -233,10 +230,10 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
// are tolerant (skipped + logged → BadNodeIdUnknown at read, "a miss is a miss") — a single
|
||||
// bad tag must not fail the whole driver init. The ONE hard failure is an unknown device
|
||||
// segment: a tag routed to a device not in the Devices list is a driver-level config error
|
||||
// that fails fast (Driver.FOCAS-003).
|
||||
// that fails fast.
|
||||
_skippedTags.BeginRebuild(_options.RawTags.Count);
|
||||
// A re-initialise without teardown (#630) rebuilds the tables from scratch: a tag the new config
|
||||
// removed — or moved between the value and alarm tables (#647) — must not survive in the old one.
|
||||
// A re-initialise without teardown rebuilds the tables from scratch: a tag the new config
|
||||
// removed — or moved between the value and alarm tables — must not survive in the old one.
|
||||
// Built into fresh tables and swapped in below, never cleared in place under a live reader.
|
||||
var tagsByRawPath = new Dictionary<string, FocasTagDefinition>(StringComparer.Ordinal);
|
||||
var parsedAddresses = new ConcurrentDictionary<string, FocasAddress>(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -265,7 +262,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
$"which is not in the Devices list. Check for a typo (e.g. wrong port or hostname).");
|
||||
// Per-tag address / capability-matrix validation is tolerant: an unparseable address or a
|
||||
// capability-matrix violation skips the tag (→ BadNodeIdUnknown at read) rather than failing
|
||||
// the whole init (R2-11 05/UNDER-6 — the same pre-flight, surfacing a resolver miss).
|
||||
// the whole init (the same pre-flight, surfacing a resolver miss).
|
||||
var parsed = FocasAddress.TryParse(def.Address);
|
||||
if (parsed is null)
|
||||
{
|
||||
@@ -283,7 +280,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
entry.RawPath, def.Address, reason, _driverInstanceId);
|
||||
continue;
|
||||
}
|
||||
// Gitea #647: an ALARM: address and the cross-driver alarm object only work together — the object
|
||||
// an ALARM: address and the cross-driver alarm object only work together — the object
|
||||
// is what makes the host materialise a condition at this RawPath, the address is the only thing
|
||||
// this driver can drive one from. Either alone does nothing, so it is skipped and reported.
|
||||
if (parsed.Kind == FocasAreaKind.Alarm || def.Alarm is not null)
|
||||
@@ -355,7 +352,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
}
|
||||
|
||||
// Restart the alarm poll a re-initialise paused, against the tables just rebuilt (Gitea #647). A
|
||||
// Restart the alarm poll a re-initialise paused, against the tables just rebuilt. A
|
||||
// driver with no subscription yet starts nothing — the actor subscribes once it is Connected.
|
||||
await _alarmProjection.ResumeAsync().ConfigureAwait(false);
|
||||
|
||||
@@ -375,7 +372,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// </summary>
|
||||
private async Task ReleaseDeviceStatesAsync()
|
||||
{
|
||||
// Pause, not dispose (Gitea #647): the subscription and what the server was told survive the rebuild.
|
||||
// Pause, not dispose: the subscription and what the server was told survive the rebuild.
|
||||
await _alarmProjection.PauseAsync().ConfigureAwait(false);
|
||||
if (_devices.Count == 0) return;
|
||||
var loops = new List<Task>();
|
||||
@@ -429,9 +426,9 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
public async Task ShutdownAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _poll.DisposeAsync().ConfigureAwait(false);
|
||||
// Pause, not dispose (Gitea #647) — see _alarmProjection. A later InitializeAsync resumes it.
|
||||
// Pause, not dispose — see _alarmProjection. A later InitializeAsync resumes it.
|
||||
await _alarmProjection.PauseAsync().ConfigureAwait(false);
|
||||
// STAB-23: cancel every loop first, JOIN them (bounded), and only then dispose the CTSs and the
|
||||
// cancel every loop first, JOIN them (bounded), and only then dispose the CTSs and the
|
||||
// client. Fire-and-forget loops used to outlive shutdown: one parked in EnsureConnectedAsync could
|
||||
// finish its connect AFTER DisposeClient and leave a live socket on a shut-down driver.
|
||||
var loops = new List<Task>();
|
||||
@@ -481,12 +478,10 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// The worst of the value path (<see cref="_health"/>) and the alarm path (<see cref="_alarmTransportFailures"/>),
|
||||
/// computed together (Gitea #647 re-review R3). <see cref="DriverHealth.LastSuccessfulRead"/> is the later of
|
||||
/// the two paths' last successes: while either path still reads, the comms-loss watchdog stays quiet; when both
|
||||
/// have stopped it is frozen. Faulted / Unknown / Initializing come from the value (lifecycle) path unchanged.
|
||||
/// </remarks>
|
||||
// The worst of the value path (_health) and the alarm path (_alarmTransportFailures),
|
||||
// computed together. LastSuccessfulRead is the later of the two paths' last successes: while either
|
||||
// path still reads, the comms-loss watchdog stays quiet; when both have stopped it is frozen.
|
||||
// Faulted / Unknown / Initializing come from the value (lifecycle) path unchanged.
|
||||
public DriverHealth GetHealth()
|
||||
{
|
||||
var value = Volatile.Read(ref _health);
|
||||
@@ -524,7 +519,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
// Recovers the host by matching the RawTagEntry's DeviceName against the configured Devices list (which
|
||||
// DriverDeviceConfigMerger fills from each Device row's DeviceConfig). By DeviceName, falling back to
|
||||
// HostAddress (the Client CLI passes the host as the DeviceName); for a single-device driver an empty
|
||||
// DeviceName routes to the sole device. Returns null when no device matches — the caller fails the tag fast (Driver.FOCAS-003).
|
||||
// DeviceName routes to the sole device. Returns null when no device matches — the caller fails the tag fast.
|
||||
private string? ResolveDeviceHost(string deviceName)
|
||||
{
|
||||
foreach (var d in _options.Devices)
|
||||
@@ -560,7 +555,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
var now = DateTime.UtcNow;
|
||||
var results = new DataValueSnapshot[fullReferences.Count];
|
||||
|
||||
// #266 — merge adjacent / near PMC reads of one device into fewer pmc_rdpmcrng calls. Indices it
|
||||
// Merge adjacent / near PMC reads of one device into fewer pmc_rdpmcrng calls. Indices it
|
||||
// serves are marked here and skipped by the per-tag loop below; everything else (including every
|
||||
// single-tag range and any range the CNC rejected) takes the unchanged per-tag path.
|
||||
var served = _options.PmcCoalesce.Enabled
|
||||
@@ -578,14 +573,14 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
//
|
||||
// This branch matches a reference that IS a "{host}/{path}" fixed-tree key. Authored
|
||||
// tags do not take it — their reference is a RawPath — and are served further down by
|
||||
// their FIXED: address instead (Gitea #540).
|
||||
// their FIXED: address instead.
|
||||
if (_fixedTreeActive && TryReadFixedTree(reference, now) is { } fx)
|
||||
{
|
||||
results[i] = fx;
|
||||
continue;
|
||||
}
|
||||
|
||||
// An ALARM: tag is a Part 9 condition, not a value (Gitea #647) — there is nothing to read.
|
||||
// An ALARM: tag is a Part 9 condition, not a value — there is nothing to read.
|
||||
if (Volatile.Read(ref _alarmTags).ContainsKey(reference))
|
||||
{
|
||||
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNotSupported, null, now);
|
||||
@@ -659,7 +654,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PMC coalescing pre-pass (Gitea #266). Groups the batch's PMC tags by device, plans merged ranges
|
||||
/// PMC coalescing pre-pass. Groups the batch's PMC tags by device, plans merged ranges
|
||||
/// (<see cref="FocasPmcCoalescer"/>) and serves every multi-member range with ONE
|
||||
/// <c>pmc_rdpmcrng</c>, slicing each tag's value out of the returned slots.
|
||||
/// </summary>
|
||||
@@ -791,7 +786,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
for (var i = 0; i < writes.Count; i++)
|
||||
{
|
||||
var w = writes[i];
|
||||
// An ALARM: tag is a condition, and this driver never writes to the CNC's alarm state (#647).
|
||||
// An ALARM: tag is a condition, and this driver never writes to the CNC's alarm state.
|
||||
if (Volatile.Read(ref _alarmTags).ContainsKey(w.FullReference))
|
||||
{
|
||||
results[i] = new WriteResult(FocasStatusMapper.BadNotWritable);
|
||||
@@ -873,10 +868,13 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
// node set is non-empty and stable.
|
||||
public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.UntilStable;
|
||||
|
||||
/// <summary>The FixedTree device folder is real enumeration — config-gated on FixedTree.Enabled,
|
||||
/// which the universal browser's PatchForBrowse turns on. FOCAS is the UntilStable driver: the
|
||||
/// FixedTree cache fills a couple of seconds post-connect, so the browser's settle loop re-captures
|
||||
/// until the node set is non-empty and stable.</summary>
|
||||
// <remarks>
|
||||
// The FixedTree device folder is real enumeration — config-gated on FixedTree.Enabled,
|
||||
// which the universal browser's PatchForBrowse turns on. FOCAS is the UntilStable driver: the
|
||||
// FixedTree cache fills a couple of seconds post-connect, so the browser's settle loop re-captures
|
||||
// until the node set is non-empty and stable.
|
||||
// </remarks>
|
||||
/// <inheritdoc />
|
||||
public bool SupportsOnlineDiscovery => true;
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -908,7 +906,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
{
|
||||
// The disambiguated segment, not the raw Display — a CNC reporting two
|
||||
// axes both named AA gets AA_1 / AA_2 folders instead of two identical
|
||||
// siblings the commit gate must refuse (#548).
|
||||
// siblings the commit gate must refuse.
|
||||
var segment = cache.AxisSegments[a];
|
||||
var axisFolder = axesFolder.Folder(segment, segment);
|
||||
EmitAxisVariable(axisFolder, segment, "AbsolutePosition");
|
||||
@@ -955,7 +953,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
EmitFixedVariable(opMode, "OperationMode", "ModeText", DriverDataType.String);
|
||||
}
|
||||
|
||||
// Fixed-tree T2.5 — Status subtree (cnc_statinfo flags, #257), gated on its own probe.
|
||||
// Fixed-tree T2.5 — Status subtree (cnc_statinfo flags), gated on its own probe.
|
||||
if (cache.Capabilities.Status)
|
||||
{
|
||||
var status = deviceFolder.Folder("Status", "Status");
|
||||
@@ -1012,7 +1010,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// <c>TagConfig.address</c>. Emitting the host-prefixed cache key therefore authored an
|
||||
/// address no FOCAS parser accepts: the driver skipped every such tag at init and each one
|
||||
/// read <c>BadNodeIdUnknown</c> forever, while the deploy sealed green and driver health
|
||||
/// stayed Healthy (Gitea #540).
|
||||
/// stayed Healthy.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The host is dropped rather than stripped later because a tag's device is already carried
|
||||
@@ -1067,7 +1065,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
/// <summary>
|
||||
/// The <see cref="FocasDataType"/> name a fixed-tree node binds by, for the browse-commit
|
||||
/// path (Gitea #536). The fixed tree only ever emits these three, and each has an
|
||||
/// path. The fixed tree only ever emits these three, and each has an
|
||||
/// identically-named <see cref="FocasDataType"/> member — the driver-agnostic
|
||||
/// <see cref="DriverDataType"/> cannot be reversed (Byte/Int16/Int32 all collapse to
|
||||
/// <see cref="DriverDataType.Int32"/>), so the native name is carried rather than derived.
|
||||
@@ -1117,8 +1115,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>This is an internal key, not an authorable address</b> — see
|
||||
/// <see cref="EmitIdentityVariable"/> for why the two are deliberately different strings
|
||||
/// (Gitea #540).
|
||||
/// <see cref="EmitIdentityVariable"/> for why the two are deliberately different strings.
|
||||
/// </remarks>
|
||||
/// <param name="deviceHost">The host address of the device.</param>
|
||||
/// <param name="path">The path within the fixed tree.</param>
|
||||
@@ -1240,7 +1237,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
// Published LAST (release write): LastStatus is the field callers watch for "the first
|
||||
// answer is in", and an authored FIXED:Status/* read is only servable once
|
||||
// FixedTreeCache and LastFixedPollUtc are set (TryReadFixedTreeOnDevice). Publishing it
|
||||
// first let a reader see a status while the read still returned BadCommunicationError (#673).
|
||||
// first let a reader see a status while the read still returned BadCommunicationError.
|
||||
state.LastStatus = statusProbe;
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
|
||||
@@ -1287,7 +1284,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
var axisIndex = i + 1; // FOCAS uses 1-based axis indexing
|
||||
var snap = await client.ReadDynamicAsync(axisIndex, ct).ConfigureAwait(false);
|
||||
// Keyed by the disambiguated segment, matching what DiscoverAsync emitted —
|
||||
// two same-named axes must not share (and overwrite) one cache slot (#548).
|
||||
// two same-named axes must not share (and overwrite) one cache slot.
|
||||
PublishAxisSnapshot(state, cache.AxisSegments[i], snap);
|
||||
if (i == 0) { firstAxisSnap = snap; PublishRateSnapshot(state, snap); }
|
||||
}
|
||||
@@ -1349,7 +1346,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
programPollDue = DateTime.UtcNow + programInterval;
|
||||
}
|
||||
|
||||
// Status flags (cnc_statinfo, #257) — same cadence as program/mode: run/emergency/alarm
|
||||
// Status flags (cnc_statinfo) — same cadence as program/mode: run/emergency/alarm
|
||||
// transitions are operator-timescale events and one call returns all of them.
|
||||
if (cache.Capabilities.Status
|
||||
&& programInterval > TimeSpan.Zero && DateTime.UtcNow >= statusPollDue)
|
||||
@@ -1446,8 +1443,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// servo-load read path resolves by axis segment.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The device-reported load names run through the same disambiguation as the axis list
|
||||
/// (#548): <c>cnc_rdsvmeter</c> answers per-axis in slot order, so the k-th load named
|
||||
/// The device-reported load names run through the same disambiguation as the axis list:
|
||||
/// <c>cnc_rdsvmeter</c> answers per-axis in slot order, so the k-th load named
|
||||
/// <c>AA</c> belongs to the k-th axis named <c>AA</c> and lands under the same
|
||||
/// <c>AA_k</c> segment the emitter published — without this, two same-named axes
|
||||
/// collapsed onto one dictionary entry and the second overwrote the first.
|
||||
@@ -1583,7 +1580,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
var idx = -1;
|
||||
for (var i = 0; i < spindleCache.SpindleSegments.Count; i++)
|
||||
{
|
||||
// Match on the disambiguated segment the emitter published (#548) — matching
|
||||
// Match on the disambiguated segment the emitter published — matching
|
||||
// raw Display would resolve every duplicate name to the first spindle.
|
||||
if (string.Equals(spindleCache.SpindleSegments[i], spindleName, StringComparison.OrdinalIgnoreCase)) { idx = i; break; }
|
||||
}
|
||||
@@ -1691,12 +1688,10 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
// ---- IAlarmSource ----
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// <paramref name="sourceNodeIds"/> are the RawPaths of the alarm-bearing raw tags (the host's alarm
|
||||
/// subscription set), and each becomes ONE Part 9 condition whose ConditionId is that RawPath (Gitea #647).
|
||||
/// Never throws for a disabled flag: authoring an <c>ALARM:</c> tag is the opt-in, and a RawPath that is
|
||||
/// not an <c>ALARM:</c> tag of this driver is simply never raised.
|
||||
/// </remarks>
|
||||
// sourceNodeIds are the RawPaths of the alarm-bearing raw tags (the host's alarm subscription set), and
|
||||
// each becomes ONE Part 9 condition whose ConditionId is that RawPath. Never throws for a disabled flag:
|
||||
// authoring an ALARM: tag is the opt-in, and a RawPath that is not an ALARM: tag of this driver is simply
|
||||
// never raised.
|
||||
public Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
|
||||
IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken) =>
|
||||
_alarmProjection.SubscribeAsync(sourceNodeIds);
|
||||
@@ -1706,7 +1701,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
_alarmProjection.UnsubscribeAsync(handle);
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>Read-only: nothing is written to the CNC. See <see cref="FocasAlarmProjection.AcknowledgeAsync"/>.</remarks>
|
||||
// Read-only: nothing is written to the CNC. See FocasAlarmProjection.AcknowledgeAsync.
|
||||
public Task AcknowledgeAsync(
|
||||
IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken) =>
|
||||
FocasAlarmProjection.AcknowledgeAsync(acknowledgements);
|
||||
@@ -1725,7 +1720,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The ALARM-path half of driver health (Gitea #647 re-review R3): device host → why its alarm read is failing
|
||||
/// The ALARM-path half of driver health: device host → why its alarm read is failing
|
||||
/// at the TRANSPORT. Kept apart from <see cref="_health"/> (the value path) and combined with it in
|
||||
/// <see cref="GetHealth"/>, so neither path can overwrite the other: a good value read cannot mask a failing
|
||||
/// alarm read (and flip /hosts back and forth every poll), and a good alarm read cannot mask a value-path
|
||||
@@ -1756,8 +1751,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
/// <summary>
|
||||
/// A TRANSPORT failure of an alarm read: the alarm path is Degraded until the device's next good read, and its
|
||||
/// LastSuccessfulRead stays frozen — the conjunction the comms-loss watchdog (#527) trips on, which a driver
|
||||
/// whose only tags are <c>ALARM:</c> tags otherwise never reached (Gitea #647 review).
|
||||
/// LastSuccessfulRead stays frozen — the conjunction the comms-loss watchdog trips on, which a driver
|
||||
/// whose only tags are <c>ALARM:</c> tags otherwise never reached.
|
||||
/// </summary>
|
||||
/// <param name="hostAddress">The device whose alarm read failed.</param>
|
||||
/// <param name="error">Why.</param>
|
||||
@@ -1774,7 +1769,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// <summary>
|
||||
/// Reads one device's active-alarm list. <b>Throws on any failure</b> — an unknown device, a connect that
|
||||
/// fails, a read the CNC refuses — because the projection must never mistake a failed read for an empty
|
||||
/// list (Gitea #647).
|
||||
/// list.
|
||||
/// </summary>
|
||||
/// <param name="hostAddress">The device host address.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
@@ -1789,17 +1784,16 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
|
||||
// ---- IPerCallHostResolver ----
|
||||
|
||||
/// <summary>
|
||||
/// Resolve a reference to the device host that keys its per-host resilience
|
||||
/// (bulkhead / circuit breaker). CONV-4: routes through
|
||||
/// <see cref="EquipmentTagRefResolver{TDef}"/> so an equipment-tag reference (raw TagConfig
|
||||
/// JSON) resolves to its OWN authored device rather than the first-device fallback. The
|
||||
/// empty-host guard covers FOCAS's <c>?? ""</c> default so an address-less equipment tag
|
||||
/// falls back rather than keying an empty host. The resolver caches parses, so this adds no
|
||||
/// per-call cost after first use.
|
||||
/// </summary>
|
||||
/// <param name="fullReference">The tag reference (authored name or equipment-tag JSON).</param>
|
||||
/// <returns>The device host key.</returns>
|
||||
// <remarks>
|
||||
// Resolves a reference to the device host that keys its per-host resilience
|
||||
// (bulkhead / circuit breaker). It routes through
|
||||
// <see cref="EquipmentTagRefResolver{TDef}"/> so an equipment-tag reference (raw TagConfig
|
||||
// JSON) resolves to its OWN authored device rather than the first-device fallback. The
|
||||
// empty-host guard covers FOCAS's <c>?? ""</c> default so an address-less equipment tag
|
||||
// falls back rather than keying an empty host. The resolver caches parses, so this adds no
|
||||
// per-call cost after first use.
|
||||
// </remarks>
|
||||
/// <inheritdoc />
|
||||
public string ResolveHost(string fullReference)
|
||||
{
|
||||
if (_resolver.TryResolve(fullReference, out var def) && !string.IsNullOrEmpty(def.DeviceHostAddress))
|
||||
@@ -1808,7 +1802,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thrown when a per-device <see cref="ConnectionBackoff"/> window is still open (05/STAB-8)
|
||||
/// Thrown when a per-device <see cref="ConnectionBackoff"/> window is still open
|
||||
/// — a fail-fast that costs no wire traffic. Read/write map it to <c>BadCommunicationError</c>
|
||||
/// via their generic catch, exactly as a real connect failure.
|
||||
/// </summary>
|
||||
@@ -1831,7 +1825,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
// Another caller may have connected while this one waited.
|
||||
if (device.Client is { IsConnected: true } connected) return connected;
|
||||
|
||||
// 05/STAB-8: inside an open backoff window a data-path / per-tick-loop caller fails fast — no
|
||||
// Inside an open backoff window a data-path / per-tick-loop caller fails fast — no
|
||||
// socket create, no wire connect. The probe loop bypasses this and drives failure/success.
|
||||
if (!bypassBackoff && !device.Backoff.ShouldAttempt(DateTime.UtcNow))
|
||||
throw new ConnectionThrottledException(
|
||||
@@ -1866,10 +1860,10 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
// The caller's OWN cancellation says nothing about the CNC: do not open the backoff window, or
|
||||
// every caller queued on ConnectGate behind it would be throttled for ~1 s for nothing.
|
||||
if (!(ex is OperationCanceledException && ct.IsCancellationRequested))
|
||||
device.Backoff.RecordFailure(DateTime.UtcNow); // 05/STAB-8: open the backoff window
|
||||
device.Backoff.RecordFailure(DateTime.UtcNow); // open the backoff window
|
||||
throw;
|
||||
}
|
||||
device.Backoff.RecordSuccess(); // 05/STAB-8: recovery resets the window immediately
|
||||
device.Backoff.RecordSuccess(); // recovery resets the window immediately
|
||||
return client;
|
||||
}
|
||||
finally { device.ConnectGate.Release(); }
|
||||
@@ -1895,7 +1889,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// <summary>
|
||||
/// Unique <c>Axes/{segment}</c> path segments, one per axis in slot order — bare
|
||||
/// display names when unique, occurrence-suffixed (<c>AA_1</c>, <c>AA_2</c>) when a
|
||||
/// CNC reports duplicate sibling axis names (#548). The emitter, the poll loop's
|
||||
/// CNC reports duplicate sibling axis names. The emitter, the poll loop's
|
||||
/// snapshot keys and the read path all use THIS, never <c>Display</c> directly, so
|
||||
/// they cannot disagree.
|
||||
/// </summary>
|
||||
@@ -1926,7 +1920,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
bool ServoLoad, // cnc_rdsvmeter bootstrap probe returned data
|
||||
bool ProgramInfo, // cnc_exeprgname2 + cnc_rdblkcount + cnc_rdopmode work
|
||||
bool Timers, // cnc_rdtimer works for at least PowerOn
|
||||
bool Status = false); // cnc_statinfo answered at bootstrap (#257)
|
||||
bool Status = false); // cnc_statinfo answered at bootstrap
|
||||
|
||||
internal sealed class DeviceState(FocasHostAddress parsedAddress, FocasDeviceOptions options)
|
||||
{
|
||||
@@ -1938,7 +1932,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
public IFocasClient? Client { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Per-device connect-attempt throttle (05/STAB-8). A dead CNC fails fast inside the
|
||||
/// Per-device connect-attempt throttle. A dead CNC fails fast inside the
|
||||
/// backoff window instead of paying a full two-socket reconnect on every tick of each of
|
||||
/// the read / write / probe / fixed-tree / recycle loops; the probe loop bypasses it and a
|
||||
/// successful connect resets it. The connect itself is serialized by
|
||||
@@ -1964,11 +1958,11 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
public CancellationTokenSource? RecycleCts { get; set; }
|
||||
/// <summary>Gets or sets the cancellation token source for the fixed-tree loop.</summary>
|
||||
public CancellationTokenSource? FixedTreeCts { get; set; }
|
||||
/// <summary>The probe loop task, retained so shutdown can join it (STAB-23).</summary>
|
||||
/// <summary>The probe loop task, retained so shutdown can join it.</summary>
|
||||
public Task? ProbeTask { get; set; }
|
||||
/// <summary>The recycle loop task, retained so shutdown can join it (STAB-23).</summary>
|
||||
/// <summary>The recycle loop task, retained so shutdown can join it.</summary>
|
||||
public Task? RecycleTask { get; set; }
|
||||
/// <summary>The fixed-tree loop task, retained so shutdown can join it (STAB-23).</summary>
|
||||
/// <summary>The fixed-tree loop task, retained so shutdown can join it.</summary>
|
||||
public Task? FixedTreeTask { get; set; }
|
||||
/// <summary>Gets or sets the fixed-tree cache for this device.</summary>
|
||||
public FocasFixedTreeCache? FixedTreeCache { get; set; }
|
||||
@@ -1982,7 +1976,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
/// <b>This is what stops a dead CNC publishing its last values as Good forever.</b> The
|
||||
/// fixed-tree poll loop swallows a failed tick (LogDebug + retry) by design, so a device
|
||||
/// that stops answering leaves the caches populated and unchanging with nothing marking
|
||||
/// them stale. That was harmless while nothing could bind to the fixed tree; Gitea #540
|
||||
/// them stale. That was harmless while nothing could bind to the fixed tree; binding to it
|
||||
/// makes those nodes authorable, so reads are now dated from this stamp and go Bad once
|
||||
/// it falls outside <see cref="FixedTreeStaleAfter"/>.
|
||||
/// </remarks>
|
||||
@@ -1997,10 +1991,10 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
|
||||
public ConcurrentDictionary<string, double> LastFixedSnapshots { get; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
/// <summary>
|
||||
/// PMC ranges (<see cref="FocasPmcRangePlan.Key"/>) the CNC rejected as a merged read, so later polls skip
|
||||
/// straight to per-tag reads instead of paying a doomed round-trip every cycle (#266).
|
||||
/// straight to per-tag reads instead of paying a doomed round-trip every cycle.
|
||||
/// </summary>
|
||||
public ConcurrentDictionary<string, byte> CoalesceRejected { get; } = new(StringComparer.Ordinal);
|
||||
/// <summary>Gets or sets the last <c>cnc_statinfo</c> snapshot (#257); null until the first answer.</summary>
|
||||
/// <summary>Gets or sets the last <c>cnc_statinfo</c> snapshot; null until the first answer.</summary>
|
||||
public FocasStatus? LastStatus
|
||||
{
|
||||
get => Volatile.Read(ref _lastStatus);
|
||||
|
||||
@@ -36,7 +36,7 @@ public static class FocasDriverFactoryExtensions
|
||||
/// </summary>
|
||||
/// <param name="registry">The driver factory registry to register with.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory; without it the driver and its wire client run on
|
||||
/// the null logger and none of their diagnostics reach the node log (CONV-3).</param>
|
||||
/// the null logger and none of their diagnostics reach the node log.</param>
|
||||
public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(registry);
|
||||
@@ -61,7 +61,7 @@ public static class FocasDriverFactoryExtensions
|
||||
loggerFactory?.CreateLogger<FocasDriver>(),
|
||||
// Hand the driver the SAME parse it was built with, so InitializeAsync can re-derive EVERY
|
||||
// config-derived dependency on a reinitialize — options AND the backend client factory
|
||||
// together (#516). Re-parsing options alone would run a new device/tag set against the old
|
||||
// together. Re-parsing options alone would run a new device/tag set against the old
|
||||
// backend, which is worse than not re-parsing at all.
|
||||
rebind: json => ParseBinding(driverInstanceId, json, loggerFactory));
|
||||
}
|
||||
@@ -126,7 +126,7 @@ public static class FocasDriverFactoryExtensions
|
||||
/// <paramref name="defaultMs"/> for a null / zero / negative value. A non-positive
|
||||
/// <c>Timeout</c> would make <see cref="SynchronizedFocasClient"/> DISABLE its per-call
|
||||
/// wall-clock ceiling (falling back to the caller's long-lived poll token), reintroducing the
|
||||
/// exact unbounded-read-on-a-frozen-peer wedge the S7 R2-01 read-leg fix eliminated. Clamping
|
||||
/// exact unbounded-read-on-a-frozen-peer wedge the S7 read-leg fix eliminated. Clamping
|
||||
/// here at the config boundary guarantees the deadline can never be authored away.
|
||||
/// </summary>
|
||||
/// <param name="ms">The operator-supplied timeout in milliseconds, or null.</param>
|
||||
@@ -365,7 +365,7 @@ public static class FocasDriverFactoryExtensions
|
||||
public TimeSpan? PollInterval { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Optional <c>PmcCoalesce</c> config section (#266).</summary>
|
||||
/// <summary>Optional <c>PmcCoalesce</c> config section.</summary>
|
||||
internal sealed class FocasPmcCoalesceDto
|
||||
{
|
||||
/// <summary>Gets or sets a value indicating whether coalescing is on.</summary>
|
||||
@@ -392,7 +392,7 @@ public static class FocasDriverFactoryExtensions
|
||||
/// reinitialize adopts all of it atomically.
|
||||
/// <para>This exists because a partial adoption is a real hazard, not a theoretical one: the driver's
|
||||
/// <see cref="IFocasClientFactory"/> is chosen by the config's <c>Backend</c> key, so re-parsing
|
||||
/// <see cref="Options"/> alone would poll a NEW device/tag set through the OLD backend (Gitea #516).</para>
|
||||
/// <see cref="Options"/> alone would poll a NEW device/tag set through the OLD backend.</para>
|
||||
/// </summary>
|
||||
/// <param name="Options">The typed driver options.</param>
|
||||
/// <param name="ClientFactory">The backend client factory the <c>Backend</c> key selects.</param>
|
||||
|
||||
@@ -32,7 +32,7 @@ internal sealed record FocasPmcRangePlan(
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges adjacent / near PMC reads in one poll batch into fewer <c>pmc_rdpmcrng</c> calls (Gitea #266).
|
||||
/// Merges adjacent / near PMC reads in one poll batch into fewer <c>pmc_rdpmcrng</c> calls.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Items are grouped by <c>(letter, slot width class)</c> and merged in address order while (a) the gap to
|
||||
|
||||
@@ -69,7 +69,7 @@ public interface IFocasClient : IDisposable
|
||||
/// active". The alarm projection polls this per device and diffs it into Part 9
|
||||
/// condition transitions on the driver's <c>OnAlarmEvent</c>.
|
||||
/// <para>
|
||||
/// <b>A failed read MUST throw</b>, never answer empty (Gitea #647): the projection treats an
|
||||
/// <b>A failed read MUST throw</b>, never answer empty: the projection treats an
|
||||
/// empty answer as "every alarm cleared", so a failure reported as <c>[]</c> becomes a mass
|
||||
/// clear followed by an un-acknowledged re-raise on recovery. A throw keeps the last state.
|
||||
/// </para>
|
||||
@@ -82,7 +82,7 @@ public interface IFocasClient : IDisposable
|
||||
/// <summary>
|
||||
/// Read one PMC range (<c>pmc_rdpmcrng</c>) as raw slots: <c>(end - start + 1)</c> bytes split into
|
||||
/// slots of <paramref name="slotType"/>'s width, from <paramref name="start"/>. The primitive behind PMC
|
||||
/// range coalescing (Gitea #266) — the driver slices individual tag values out of the returned slots with
|
||||
/// range coalescing — the driver slices individual tag values out of the returned slots with
|
||||
/// the same decode a per-tag read uses.
|
||||
/// </summary>
|
||||
/// <param name="letter">PMC area letter.</param>
|
||||
@@ -149,7 +149,7 @@ public interface IFocasClient : IDisposable
|
||||
/// <returns>The aggregate program + operation-mode snapshot.</returns>
|
||||
Task<FocasProgramInfo> GetProgramInfoAsync(CancellationToken cancellationToken);
|
||||
|
||||
// ---- Fixed-tree T2.5 (CNC status flags, Gitea #257) ----
|
||||
// ---- Fixed-tree T2.5 (CNC status flags) ----
|
||||
|
||||
/// <summary>
|
||||
/// Read the CNC status flags via <c>cnc_statinfo</c> (<c>ODBST</c>). Populates the
|
||||
|
||||
@@ -379,7 +379,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
/// Validity (vacant <c>0xFFFF</c> exponent, a base other than 2/10, an absurd exponent) is a
|
||||
/// per-node quality decision made by <see cref="FocasPosition.IsValid"/>, not a fault here:
|
||||
/// raising would let one empty slot take out the whole poll. Ported from the sister
|
||||
/// ignitionoee driver v0.17.0 (<c>Dynamic2.scaledPosition</c>, Focas.Wire-014).
|
||||
/// ignitionoee driver v0.17.0 (<c>Dynamic2.scaledPosition</c>).
|
||||
/// </remarks>
|
||||
/// <param name="payload">The 0x0026 response-block payload.</param>
|
||||
/// <returns>The position with whatever scale the block carried.</returns>
|
||||
@@ -602,7 +602,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
/// so reading the decimal at offset 4 decoded every macro on every control without ever
|
||||
/// failing — just wrongly (the captured <c>#500 = 1.0</c> published <c>0.01</c>). Pinned by
|
||||
/// a live Makino 31i-B capture; found and fixed by the sister ignitionoee driver
|
||||
/// (Focas.Wire-013, 2026-08-05). A payload shorter than 8 bytes carries no
|
||||
/// (2026-08-05). A payload shorter than 8 bytes carries no
|
||||
/// <c>dec_val</c> and is refused rather than guessed at.
|
||||
/// </remarks>
|
||||
/// <param name="number">The macro number the request asked for.</param>
|
||||
@@ -987,7 +987,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
/// so the load is <c>value / base^exponent</c>, a direct percentage. (A 12-byte stride
|
||||
/// misaligned after the first record; reading offset 4 as a decimal count divided every load
|
||||
/// by 10^10 — both fixed against live 31i-B captures, the latter by the sister ignitionoee
|
||||
/// driver, Focas.Wire-019.) Axis NAMES are not in the svmeter payload; they come from the
|
||||
/// driver.) Axis NAMES are not in the svmeter payload; they come from the
|
||||
/// <c>cnc_rdaxisname</c> (0x0089) block requested alongside it and are correlated by index.
|
||||
/// The payload is a fixed 32-slot buffer: slots past the controlled-axis count are residue
|
||||
/// and decode as <see cref="WireServoMeter.IsValid"/> = false.
|
||||
@@ -1104,7 +1104,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
|
||||
/// <summary>
|
||||
/// Decode a <c>cnc_rdalmmsg2</c> payload: the 76-byte vendor stride, else the 80-byte legacy one. An empty
|
||||
/// payload is "no alarms active". A non-empty payload that fits NEITHER stride is malformed and throws
|
||||
/// (Gitea #647 review): it used to fall into the legacy parser, decode zero records, and so read as "no
|
||||
/// It used to fall into the legacy parser, decode zero records, and so read as "no
|
||||
/// alarms" — a mass Clear of every condition on the device, then an un-acknowledged re-raise next poll.
|
||||
/// </summary>
|
||||
/// <param name="payload">The reply payload.</param>
|
||||
|
||||
@@ -101,7 +101,7 @@ internal static class FocasValueEnvelope
|
||||
/// Offset 4 used to be read as a decimal count ("data / 10^dec"). It is the <b>base</b> — a
|
||||
/// steady 10 — so every load was divided by 10^10 and published ~0 (a real Makino cutting at
|
||||
/// 20,000 rpm read servo 0% on every axis). Found by the sister ignitionoee driver,
|
||||
/// Focas.Wire-019 (v0.17.2).
|
||||
/// the Focas wire library (v0.17.2).
|
||||
/// </remarks>
|
||||
/// <param name="Index">1-based axis slot.</param>
|
||||
/// <param name="Name">Axis name from the companion 0x0089 block (empty when none).</param>
|
||||
@@ -141,7 +141,7 @@ public sealed record WireSpindleMetric(
|
||||
/// The meter's full-scale count: <c>16384</c> = 2^14 = 100% of rated load (type 0) or of
|
||||
/// maximum spindle speed (type 1). Settled on the real Z-34184 by regressing the type-1
|
||||
/// meter against <c>cnc_rddynamic2</c> spindle speed over 596–20,000 rpm:
|
||||
/// <c>rpm = 1.220749 × meter</c>, <c>16384 × 1.220749 = 20000.75</c> (Focas.Wire-019).
|
||||
/// <c>rpm = 1.220749 × meter</c>, <c>16384 × 1.220749 = 20000.75</c>.
|
||||
/// </summary>
|
||||
public const int FullScale = 1 << 14;
|
||||
|
||||
|
||||
@@ -246,13 +246,13 @@ internal static class FocasWireProtocol
|
||||
/// <remarks>
|
||||
/// <para><b>All four bytes are name bytes.</b> This used to read only the first two, which
|
||||
/// collapsed the real 31i-B's paired axes <c>"AA6"</c>/<c>"AA7"</c> (live capture
|
||||
/// <c>ref-axisname-0x0089.bin</c>) to two axes both called <c>"AA"</c> — the duplicate Gitea
|
||||
/// #548 then had to disambiguate as <c>AA_1</c>/<c>AA_2</c>.</para>
|
||||
/// <c>ref-axisname-0x0089.bin</c>) to two axes both called <c>"AA"</c> — the duplicate name the
|
||||
/// driver then had to disambiguate as <c>AA_1</c>/<c>AA_2</c>.</para>
|
||||
/// <para><b>The first NUL terminates, not trailing-NUL trimming.</b> A control writes only the
|
||||
/// bytes its name needs and leaves the rest of the buffer as it found it: Makino Z-34184's
|
||||
/// <c>cnc_rdspdlname</c> answered <c>53 31 00 4e</c> — <c>"S1"</c> plus the <c>'N'</c> of an
|
||||
/// earlier <c>"CNC"</c> response. Both rules are ported from the sister ignitionoee FOCAS
|
||||
/// driver (Focas.Wire-001 / Focas.Wire-022).</para>
|
||||
/// driver (see the ignitionoee driver's wire layer).</para>
|
||||
/// </remarks>
|
||||
/// <param name="bytes">The record bytes (4 on the wire; a shorter span is read as far as it goes).</param>
|
||||
/// <returns>The decoded name; empty for a blank record.</returns>
|
||||
|
||||
@@ -73,7 +73,7 @@ public sealed class WireFocasClient : IFocasClient
|
||||
{
|
||||
// Contract: a RETURNED status is always a CNC return code (a refusal); a transport failure THROWS.
|
||||
// The coalescing caller needs that distinction — it falls back per tag on a refusal but must not
|
||||
// re-hit a dead link (#266).
|
||||
// re-hit a dead link.
|
||||
RequireConnected();
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
@@ -115,18 +115,16 @@ public sealed class WireFocasClient : IFocasClient
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Gitea #647: every failure THROWS — a disconnected session, a non-OK return code and a wire fault
|
||||
/// alike. This used to answer <c>[]</c> for all three, which the alarm projection could not tell
|
||||
/// from "no alarms active": every comms blip cleared every active alarm, and recovery re-raised them
|
||||
/// un-acknowledged. <see cref="FocasWireException"/> propagates as-is; a refusal (non-OK rc) is thrown carrying its <c>Rc</c>.
|
||||
/// </remarks>
|
||||
public async Task<IReadOnlyList<FocasActiveAlarm>> ReadAlarmsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Every failure THROWS — a disconnected session, a non-OK return code and a wire fault
|
||||
// alike. This used to answer [] for all three, which the alarm projection could not tell
|
||||
// from "no alarms active": every comms blip cleared every active alarm, and recovery re-raised them
|
||||
// un-acknowledged. FocasWireException propagates as-is; a refusal (non-OK rc) is thrown carrying its Rc.
|
||||
RequireConnected();
|
||||
var result = await _wire.ReadAlarmsAsync(FocasAlarmType.All, MaxAlarmsPerRead, cancellationToken).ConfigureAwait(false);
|
||||
// A non-OK return code is the CNC REFUSING the call over a healthy transport — thrown carrying the rc, so
|
||||
// the alarm projection can tell it from comms loss and keep it out of driver health (#647 re-review R2).
|
||||
// the alarm projection can tell it from comms loss and keep it out of driver health.
|
||||
if (!result.IsOk)
|
||||
throw new FocasWireException($"cnc_rdalmmsg2 refused by the CNC (EW_{result.Rc}).", result.Rc);
|
||||
return (result.Value ?? []).Select(Map).ToList();
|
||||
@@ -167,6 +165,9 @@ public sealed class WireFocasClient : IFocasClient
|
||||
return MapStatus(result.Value!);
|
||||
}
|
||||
|
||||
/// <summary>Maps the wire-level status snapshot to the driver's status record.</summary>
|
||||
/// <param name="s">The wire status.</param>
|
||||
/// <returns>The mapped status.</returns>
|
||||
internal static FocasStatus MapStatus(WireStatus s) =>
|
||||
new(s.Auto, s.Run, s.Motion, s.Mstb, s.Emergency, s.Alarm, s.Edit, s.TmMode);
|
||||
|
||||
@@ -426,7 +427,7 @@ public sealed class WireFocasClient : IFocasClient
|
||||
/// integer tag is <see cref="FocasStatusMapper.BadTypeMismatch"/> — truncating or rounding
|
||||
/// would publish a plausible wrong count at Good quality — and a whole value that does not
|
||||
/// fit the type is <see cref="FocasStatusMapper.BadOutOfRange"/>. Ported from the sister
|
||||
/// ignitionoee driver's <c>macroInteger</c>/<c>macroReal</c> (Focas-017/018).</para>
|
||||
/// ignitionoee driver's <c>macroInteger</c>/<c>macroReal</c>.</para>
|
||||
/// </remarks>
|
||||
/// <param name="m">The decoded macro.</param>
|
||||
/// <param name="type">The tag's declared data type.</param>
|
||||
@@ -472,7 +473,7 @@ public sealed class WireFocasClient : IFocasClient
|
||||
|
||||
/// <summary>
|
||||
/// Records requested per <c>cnc_rdalmmsg2</c> call. A reply carrying exactly this many may have been
|
||||
/// truncated by the CNC; the alarm projection warns about it (Gitea #647).
|
||||
/// truncated by the CNC; the alarm projection warns about it.
|
||||
/// </summary>
|
||||
internal const short MaxAlarmsPerRead = 32;
|
||||
|
||||
|
||||
@@ -47,15 +47,11 @@ public sealed class GalaxyDriverBrowser : IDriverBrowser
|
||||
_logger = logger ?? NullLogger<GalaxyDriverBrowser>.Instance;
|
||||
}
|
||||
|
||||
// Sourced from the shared DriverTypeNames set, reached through Commons. This project references
|
||||
// Driver.Galaxy.Contracts, not Driver.Galaxy, so GalaxyDriverFactoryExtensions.DriverTypeName is
|
||||
// not available here; DriverTypeNames.Galaxy holds the same value and is the constant
|
||||
// DriverTypeNamesGuardTests pins to the factory's registration, so the string is never hand-written.
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Sourced from the shared <see cref="DriverTypeNames"/> set, reached through
|
||||
/// <c>Commons</c>. This project references Driver.Galaxy.Contracts, not Driver.Galaxy, so
|
||||
/// <c>GalaxyDriverFactoryExtensions.DriverTypeName</c> is not available here — but that was never a
|
||||
/// reason to hand-write the string, which is what this line used to do (Gitea #558):
|
||||
/// <see cref="DriverTypeNames.Galaxy"/> holds the same value and is the constant
|
||||
/// <c>DriverTypeNamesGuardTests</c> already pins to the factory's registration.
|
||||
/// </remarks>
|
||||
public string DriverType => DriverTypeNames.Galaxy;
|
||||
|
||||
// Deserializes a GalaxyDriverOptions blob, opens a transient GalaxyRepositoryClient
|
||||
|
||||
@@ -40,7 +40,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy;
|
||||
public sealed class GalaxyDriver
|
||||
: IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IRediscoverable, IHostConnectivityProbe, IAlarmSource, ISkippedTagReporter, IDisposable, IAsyncDisposable
|
||||
{
|
||||
/// <summary>Authored tags this driver could not dial (Gitea #541) — a TagConfig with no usable
|
||||
/// <summary>Authored tags this driver could not dial — a TagConfig with no usable
|
||||
/// <c>attributeRef</c> has no MXAccess address to bind.</summary>
|
||||
private readonly SkippedTagLedger _skippedTags = new();
|
||||
|
||||
@@ -162,7 +162,7 @@ public sealed class GalaxyDriver
|
||||
/// <summary>Fires when a host transitions Running ↔ Stopped (PR 4.7 HostStatusAggregator).</summary>
|
||||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Raised when the gateway reports an alarm event for a subscribed Galaxy attribute.</summary>
|
||||
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="GalaxyDriver"/> class.</summary>
|
||||
@@ -256,7 +256,7 @@ public sealed class GalaxyDriver
|
||||
{
|
||||
// Counted, though there is no path to name it by: an artifact carrying such an entry
|
||||
// is malformed, and reporting "N authored, N-1 bound" with nothing accounting for the
|
||||
// difference is exactly the silence #541 is about.
|
||||
// difference is exactly the silence the skipped-tag count exists to prevent.
|
||||
skipped.Skip("", "entry has no RawPath");
|
||||
continue;
|
||||
}
|
||||
@@ -347,15 +347,13 @@ public sealed class GalaxyDriver
|
||||
internal Task InvokeReplayForTestAsync(CancellationToken cancellationToken) =>
|
||||
ReplayAsync(cancellationToken);
|
||||
|
||||
// InitializeAsync takes a config document, and the IDriver contract is that a driver serves the
|
||||
// one it is handed (the runtime's own mid-connect adoption path was deleted). The driver cannot
|
||||
// hot-swap its options (they shape the gw session; see ReinitializeAsync), so a non-equivalent
|
||||
// config with a real body is REFUSED here too rather than silently ignored - the driver would
|
||||
// otherwise connect on its old endpoint while the deploy reported the change applied. A "{}" /
|
||||
// empty document is the unit-test lifecycle shape and keeps the constructor options.
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// #664: <c>InitializeAsync</c> takes a config document, and the <see cref="IDriver"/> contract is that a
|
||||
/// driver serves the one it is handed (the runtime's own mid-connect adoption path was deleted in #665). The
|
||||
/// driver cannot hot-swap its options (they shape the gw session; see <see cref="ReinitializeAsync"/>), so a
|
||||
/// non-equivalent config with a real body is REFUSED here too rather than silently ignored — the driver
|
||||
/// would otherwise connect on its old endpoint while the deploy reported the change applied. A
|
||||
/// <c>"{}"</c> / empty document is the unit-test lifecycle shape and keeps the constructor options.
|
||||
/// </remarks>
|
||||
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
@@ -676,7 +674,7 @@ public sealed class GalaxyDriver
|
||||
/// <summary>
|
||||
/// Throw <see cref="NotSupportedException"/> unless <paramref name="driverConfigJson"/> describes options
|
||||
/// runtime-equivalent to the live ones (see <see cref="OptionsAreEquivalent"/>). Shared by
|
||||
/// <see cref="ReinitializeAsync"/> and <see cref="InitializeAsync"/> (#664) so neither path can silently
|
||||
/// <see cref="ReinitializeAsync"/> and <see cref="InitializeAsync"/> so neither path can silently
|
||||
/// ignore a config change it cannot apply.
|
||||
/// </summary>
|
||||
private void RefuseNonEquivalentConfig(string driverConfigJson)
|
||||
@@ -1444,7 +1442,7 @@ public sealed class GalaxyDriver
|
||||
GalaxyAlarmTransitionKind.ActiveAcknowledged => Core.Abstractions.AlarmTransitionKind.ActiveAcknowledged,
|
||||
_ => Core.Abstractions.AlarmTransitionKind.Unspecified,
|
||||
},
|
||||
// #656: a snapshot entry re-states upstream state — history-only signal for the host.
|
||||
// A snapshot entry re-states upstream state — history-only signal for the host.
|
||||
IsRestatement: transition.IsRestatement);
|
||||
try
|
||||
{
|
||||
|
||||
@@ -175,7 +175,7 @@ internal sealed class EventPump : IAsyncDisposable
|
||||
// just as fatal to this driver as a fault (the pump is now dead and no further
|
||||
// data-change events can ever arrive) but it raises nothing, so without this the
|
||||
// pump exits silently, the supervisor is never told, GetHealth() keeps reporting
|
||||
// Healthy, and the #527 comms-loss watchdog never trips either — it requires a
|
||||
// Healthy, and the comms-loss watchdog never trips either — it requires a
|
||||
// non-Healthy state. The result is a driver that is permanently, invisibly deaf.
|
||||
if (!ct.IsCancellationRequested)
|
||||
{
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
||||
/// the client-surface refresh PR (E.7).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>IsRestatement</c> (Gitea #656) is true for an active-alarm SNAPSHOT entry — upstream re-stating its current
|
||||
/// <c>IsRestatement</c> is true for an active-alarm SNAPSHOT entry — upstream re-stating its current
|
||||
/// state on (re)connect, not a transition the gateway watched happen. It rides to
|
||||
/// <see cref="AlarmEventArgs.IsRestatement"/> so the host writes no duplicate history row for it.
|
||||
/// </remarks>
|
||||
@@ -41,7 +41,7 @@ internal enum GalaxyAlarmTransitionKind
|
||||
Retrigger = 4,
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #656 — the alarm is active AND acknowledged (a snapshot <c>ActiveAcked</c> entry). Maps to
|
||||
/// The alarm is active AND acknowledged (a snapshot <c>ActiveAcked</c> entry). Maps to
|
||||
/// <see cref="AlarmTransitionKind.ActiveAcknowledged"/>, which the host applies as one projection.
|
||||
/// </summary>
|
||||
ActiveAcknowledged = 5,
|
||||
|
||||
@@ -175,14 +175,14 @@ internal sealed class GatewayGalaxyAlarmFeed : IGalaxyAlarmFeed
|
||||
/// <see cref="GalaxyAlarmTransitionKind.ActiveAcknowledged"/>, an inactive one as a
|
||||
/// <see cref="GalaxyAlarmTransitionKind.Clear"/>. Every entry is flagged
|
||||
/// <see cref="GalaxyAlarmTransition.IsRestatement"/>.
|
||||
/// <para>Retrigger, not Raise (Gitea #647 review): the host treats a Raise on a condition it already
|
||||
/// <para>Retrigger, not Raise: the host treats a Raise on a condition it already
|
||||
/// shows Active as a re-statement that KEEPS the acknowledgement. A snapshot re-states upstream state and
|
||||
/// the upstream's ack state is authoritative, so an unacked entry must un-ack a host condition still
|
||||
/// holding an ack from before the reconnect.</para>
|
||||
/// <para>ActiveAcknowledged, not Acknowledge (Gitea #656): an Acknowledge keeps the host's prior Active
|
||||
/// <para>ActiveAcknowledged, not Acknowledge: an Acknowledge keeps the host's prior Active
|
||||
/// state, so an acked active alarm the host did not already show active (a cold start, or one that went
|
||||
/// active while the gateway was unreachable) projected INACTIVE+acked — invisible to clients.</para>
|
||||
/// <para>IsRestatement (Gitea #656): every gateway reconnect replays the snapshot, so without the flag each
|
||||
/// <para>IsRestatement: every gateway reconnect replays the snapshot, so without the flag each
|
||||
/// replay wrote a duplicate "Activated" (or "Cleared") history row for an alarm whose node did not change.
|
||||
/// Live <see cref="DispatchTransition"/> events are never flagged — a live Retrigger is a real re-occurrence.</para>
|
||||
/// </summary>
|
||||
|
||||
@@ -168,7 +168,7 @@ public sealed class GatewayGalaxyDataWriter : IGalaxyDataWriter
|
||||
// the write is refused by the automation object (SECURITY_ERROR detail 1008 on the
|
||||
// correlated reply — live-captured). FAIL-CLOSED (archreview 06/S-1): when
|
||||
// AdviseSupervisory fails we do NOT issue the raw Write; we return Bad so the
|
||||
// #5 write-outcome self-correction reverts the phantom-Good node instead of leaving it.
|
||||
// Write-outcome self-correction reverts the phantom-Good node instead of leaving it.
|
||||
// A correlated gateway (mxaccessgw ≥ 53f69cd) would also surface the refusal in the
|
||||
// reply's statuses[0], but the short-circuit avoids a write we know cannot commit.
|
||||
if (!await EnsureSupervisoryAdvisedAsync(ops, itemHandle, ct).ConfigureAwait(false))
|
||||
|
||||
@@ -41,7 +41,7 @@ internal static class MxValueEncoder
|
||||
case bool[] arr: return EncodeArray(arr, (mx, vs) => mx.BoolValues = ToBoolArray(vs));
|
||||
// Exact-type guards: CLR array covariance makes `uint[] is int[]` and `ulong[] is long[]` true, so
|
||||
// without them an unsigned array would be encoded by REINTERPRETING its bits (uint.MaxValue → -1) —
|
||||
// unlike the range-checked scalar arms above. An unsigned array is refused instead (Gitea #610 review).
|
||||
// unlike the range-checked scalar arms above. An unsigned array is refused instead.
|
||||
case int[] arr when arr.GetType() == typeof(int[]):
|
||||
return EncodeArray(arr, (mx, vs) => mx.Int32Values = ToInt32Array(vs));
|
||||
case long[] arr when arr.GetType() == typeof(long[]):
|
||||
|
||||
@@ -30,7 +30,7 @@ internal static class StatusCodeMap
|
||||
// The substatus nibbles are NOT derivable from the OPC DA quality byte this mapper consumes —
|
||||
// they are an unrelated OPC UA enumeration and have to be looked up. Five constants here were
|
||||
// originally written as though the DA byte could be shifted into the substatus position, which
|
||||
// produced values naming entirely different UA codes (Gitea #497): UncertainLastUsableValue held
|
||||
// produced values naming entirely different UA codes: UncertainLastUsableValue held
|
||||
// UncertainDataSubNormal's value, UncertainSubNormal held UncertainNoCommunicationLastUsableValue's,
|
||||
// and GoodLocalOverride / UncertainSensorNotAccurate / UncertainEngineeringUnitsExceeded held values
|
||||
// that are not OPC UA status codes at all. StatusCodeParityTests now checks every constant below
|
||||
|
||||
@@ -6,8 +6,8 @@ using ZB.MOM.WW.OtOpcUa.Driver.Historian.Sql.Common;
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #693: HistoryRead AtTime on the Gateway backend, served from RAW gateway reads and evaluated with the SQL backends'
|
||||
/// shared <see cref="AtTimeEvaluator"/> (Part 11 §6.5.4) — as #681 did for the per-sample aggregates — so the Gateway
|
||||
/// HistoryRead AtTime on the Gateway backend, served from RAW gateway reads and evaluated with the SQL backends'
|
||||
/// shared <see cref="AtTimeEvaluator"/> (Part 11 §6.5.4) — so the Gateway
|
||||
/// answers exactly what SqlServer and QuestDB answer for the same stored samples. AVEVA's own interpolated answer (the
|
||||
/// gateway's <c>ReadAtTime</c>) is not used: live, it returned the previous value as Good at a stored NULL's own time,
|
||||
/// null + Bad at a Good sample right after a NULL, a drifted value (99.99999999944) at an exact stored 100, and no
|
||||
@@ -51,11 +51,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway;
|
||||
/// <b>Wire shape.</b> Every request bound is a whole millisecond (AVEVA stores milliseconds and truncates request
|
||||
/// times); a row counts only strictly INSIDE its request — AVEVA's synthetic boundary row at the query start (the value
|
||||
/// in effect there, <c>Quality = 133</c>, or a NULL row) can never be taken for a stored sample, nor a possible row at
|
||||
/// the query end; the epoch and before hold nothing. A Bad row (a stored NULL arrives as value 0, <c>q=1 opc=0</c>,
|
||||
/// #690) is value-less. Every stream is drained, never abandoned (a cancelled gateway stream evicts its pooled
|
||||
/// the query end; the epoch and before hold nothing. A Bad row (a stored NULL arrives as value 0, <c>q=1 opc=0</c>)
|
||||
/// is value-less. Every stream is drained, never abandoned (a cancelled gateway stream evicts its pooled
|
||||
/// historian session). Nothing is asked past <c>now + </c><see cref="RawAggregateReader.LookAheadHorizon"/> (the
|
||||
/// historian stores nothing further ahead, and the gateway's AVEVA may refuse a far-future end); a strict gateway's
|
||||
/// row ceiling (#691.3) is learned and honoured — a short read is ordinary paging here, never re-probed.
|
||||
/// row ceiling is learned and honoured — a short read is ordinary paging here, never re-probed.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Values.</b> A gateway value is a Double or a String whatever the node's data type (AVEVA stores every historized
|
||||
@@ -106,7 +106,7 @@ internal static class GatewayAtTimeReader
|
||||
|
||||
/// <summary>Evaluates AtTime for <paramref name="timestampsUtc"/> (same length and order, duplicates included).</summary>
|
||||
/// <param name="client">The gateway client.</param>
|
||||
/// <param name="ceiling">The data source's learned gateway row ceiling (#691.3).</param>
|
||||
/// <param name="ceiling">The data source's learned gateway row ceiling.</param>
|
||||
/// <param name="tag">Historian tag name.</param>
|
||||
/// <param name="timestampsUtc">Requested times (any kind; treated as UTC, as on the SQL backends).</param>
|
||||
/// <param name="now">Server timestamp, the beyond-now rule and the look-ahead horizon.</param>
|
||||
@@ -143,12 +143,25 @@ internal static class GatewayAtTimeReader
|
||||
private sealed class Ctx(IHistorianGatewayClient client, GatewayRowCeiling ceiling, string tag, CancellationToken ct,
|
||||
TypeCode? valueType, int maxReads)
|
||||
{
|
||||
/// <summary>The gateway client the reads are issued against.</summary>
|
||||
public IHistorianGatewayClient Client { get; } = client;
|
||||
|
||||
/// <summary>The data source's learned gateway row ceiling.</summary>
|
||||
public GatewayRowCeiling Ceiling { get; } = ceiling;
|
||||
|
||||
/// <summary>The historian tag being read.</summary>
|
||||
public string Tag { get; } = tag;
|
||||
|
||||
/// <summary>The cancellation token for the request.</summary>
|
||||
public CancellationToken Ct { get; } = ct;
|
||||
|
||||
/// <summary>The node's value type hint, or null when unknown.</summary>
|
||||
public TypeCode? ValueType { get; } = valueType;
|
||||
|
||||
/// <summary>The maximum number of gateway reads the request may issue.</summary>
|
||||
public int MaxReads { get; } = maxReads;
|
||||
|
||||
/// <summary>The number of gateway reads issued so far.</summary>
|
||||
public int Reads { get; set; }
|
||||
|
||||
/// <summary>The smallest budget a read was served with (a strict gateway's ceiling shows here).</summary>
|
||||
@@ -227,7 +240,7 @@ internal static class GatewayAtTimeReader
|
||||
{
|
||||
if (!beforeKnown)
|
||||
{
|
||||
// #696: prove the future stretch empty before jumping to now. Historian clocks or imports
|
||||
// prove the future stretch empty before jumping to now. Historian clocks or imports
|
||||
// can place real rows there; a full bounded probe seeds the original backward search.
|
||||
var searchEnd = qs;
|
||||
if (qs > now && walkKnown is null)
|
||||
@@ -457,7 +470,7 @@ internal static class GatewayAtTimeReader
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A gateway row as a stored sample of the node's type (see the type remarks); a Bad row carries no value (#690: a
|
||||
/// A gateway row as a stored sample of the node's type (see the type remarks); a Bad row carries no value (a
|
||||
/// stored NULL arrives as value 0).
|
||||
/// </summary>
|
||||
private static StoredSample ToStored(HistorianSample raw, DateTime t, TypeCode? valueType)
|
||||
|
||||
+82
-98
@@ -28,14 +28,14 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
|
||||
{
|
||||
/// <summary>
|
||||
/// The processed (aggregate) functions the Gateway backend serves — the single source of truth for this backend's
|
||||
/// aggregate set (#681): the per-sample aggregates evaluated from raw reads (every member of the
|
||||
/// aggregate set: the per-sample aggregates evaluated from raw reads (every member of the
|
||||
/// <see cref="AggregateFamily.Simple"/> / <see cref="AggregateFamily.StartEnd"/> /
|
||||
/// <see cref="AggregateFamily.ActualTime"/> families of <see cref="AggregateFamilies"/>: Average, Count, Minimum,
|
||||
/// Maximum, Range, Start, End, Delta, MinimumActualTime, MaximumActualTime) plus the two answered natively by AVEVA
|
||||
/// (TimeAverage, Total — <see cref="AggregateModeMapper"/>). Any other aggregate throws
|
||||
/// <see cref="HistoryAggregateNotSupportedException"/> without calling the gateway. It is what
|
||||
/// <see cref="SupportedAggregates"/> returns, so the advertised <c>AggregateFunctions</c> folders and the server's
|
||||
/// up-front <c>BadAggregateNotSupported</c> read it too (#682).
|
||||
/// up-front <c>BadAggregateNotSupported</c> read it too.
|
||||
/// </summary>
|
||||
public static IReadOnlySet<HistoryAggregateType> ServedAggregates { get; } =
|
||||
Enum.GetValues<HistoryAggregateType>()
|
||||
@@ -48,26 +48,26 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
|
||||
private readonly TimeProvider _time;
|
||||
private readonly bool _atTimeFromRaw;
|
||||
|
||||
/// <summary>#693: AtTime from raw reads (true) or AVEVA's own interpolation (the kill switch).</summary>
|
||||
/// <summary>AtTime from raw reads (true) or AVEVA's own interpolation (the kill switch).</summary>
|
||||
internal bool AtTimeFromRaw => _atTimeFromRaw;
|
||||
|
||||
/// <summary>The most #687 crowding re-reads one raw read makes (#694 F4): each at least doubles the pre-window room.</summary>
|
||||
/// <summary>The most crowding re-reads one raw read makes: each at least doubles the pre-window room.</summary>
|
||||
private const int MaxCrowdedReReads = 8;
|
||||
|
||||
/// <summary>The default of <c>maxPreWindowRows</c>: <c>ServerHistorian:MaxTieClusterOverfetch</c>'s default.</summary>
|
||||
public const int DefaultMaxPreWindowRows = 65536;
|
||||
|
||||
/// <summary>
|
||||
/// The most rows before a raw window (inside the 1 ms look-back) a crowding re-read makes room for (#694 R2) — the
|
||||
/// The most rows before a raw window (inside the 1 ms look-back) a crowding re-read makes room for — the
|
||||
/// server's tie-cluster limit (<c>ServerHistorian:MaxTieClusterOverfetch</c>), which every other tie path treats as
|
||||
/// the limit. More ⇒ <see cref="HistoryReadLimitExceededException"/> with <c>PreWindowRows</c> set.
|
||||
/// </summary>
|
||||
private readonly int _maxPreWindowRows;
|
||||
|
||||
/// <summary>The tie-cluster limit the crowding re-reads honour (#694 R2) — exposed so the factory wiring is testable.</summary>
|
||||
/// <summary>The tie-cluster limit the crowding re-reads honour — exposed so the factory wiring is testable.</summary>
|
||||
internal int MaxPreWindowRows => _maxPreWindowRows;
|
||||
|
||||
/// <summary>A strict gateway's row ceiling, learned from its first refusal (#691.3).</summary>
|
||||
/// <summary>A strict gateway's row ceiling, learned from its first refusal.</summary>
|
||||
private readonly GatewayRowCeiling _ceiling;
|
||||
private readonly GatewayTagPresenceCache _tags;
|
||||
|
||||
@@ -89,11 +89,11 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
|
||||
/// </param>
|
||||
/// <param name="timeProvider">Clock for result server timestamps and the raw look-ahead horizon; null = system.</param>
|
||||
/// <param name="atTimeFromRaw">
|
||||
/// #693: serve AtTime from raw reads with the shared AtTime rules (<see cref="GatewayAtTimeReader"/>; default);
|
||||
/// serve AtTime from raw reads with the shared AtTime rules (<see cref="GatewayAtTimeReader"/>; default);
|
||||
/// <c>false</c> = AVEVA's own interpolation passed through (<c>ServerHistorian:GatewayAtTimeFromRaw</c> kill switch).
|
||||
/// </param>
|
||||
/// <param name="maxPreWindowRows">
|
||||
/// The most rows before a raw window a crowding re-read makes room for (#694 R2; <c>ServerHistorian:MaxTieClusterOverfetch</c>,
|
||||
/// The most rows before a raw window a crowding re-read makes room for (<c>ServerHistorian:MaxTieClusterOverfetch</c>,
|
||||
/// values below 1 count as 1). More ⇒ <see cref="HistoryReadLimitExceededException"/> (<c>BadTooManyOperations</c>).
|
||||
/// </param>
|
||||
public GatewayHistorianDataSource(IHistorianGatewayClient client, ILogger<GatewayHistorianDataSource> logger,
|
||||
@@ -113,13 +113,11 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// A reverse window (<c>start > end</c>) is refused with <see cref="NotSupportedException"/> before anything is sent
|
||||
/// and is not recorded as a health failure (Gitea #686): the gateway rejects it <c>InvalidArgument</c>, and the server
|
||||
/// never asks — <see cref="SupportsReverseRaw"/> is <c>false</c>, so it walks a reverse read back with forward reads.
|
||||
/// Stored samples only (#687 — Part 11 Raw): AVEVA's synthetic start-boundary row is never returned, see
|
||||
/// <see cref="ReadRawWindowAsync"/>.
|
||||
/// </remarks>
|
||||
// A reverse window (start > end) is refused with NotSupportedException before anything is sent
|
||||
// and is not recorded as a health failure: the gateway rejects it InvalidArgument, and the server
|
||||
// never asks — SupportsReverseRaw is false, so it walks a reverse read back with forward reads.
|
||||
// Stored samples only (Part 11 Raw): AVEVA's synthetic start-boundary row is never returned, see
|
||||
// ReadRawWindowAsync.
|
||||
public async Task<HistoryReadResult> ReadRawAsync(
|
||||
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -154,7 +152,7 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
|
||||
}
|
||||
catch (HistoryReadLimitExceededException)
|
||||
{
|
||||
// A client-request limit (#687: the pre-window margin swallowed the row budget), not a backend fault.
|
||||
// A client-request limit (the pre-window margin swallowed the row budget), not a backend fault.
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -167,16 +165,16 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
|
||||
|
||||
/// <summary>
|
||||
/// The stored samples of <c>[startUtc, endUtc)</c> (a point read <c>start == end</c>: of that instant), oldest first,
|
||||
/// at most <paramref name="maxValuesPerNode"/> (<c>0</c> = all) — without AVEVA's synthetic rows (#687).
|
||||
/// at most <paramref name="maxValuesPerNode"/> (<c>0</c> = all) — without AVEVA's synthetic rows.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// An AVEVA <c>Full</c> raw read starts with a row stamped exactly at the query start: the value in effect there
|
||||
/// (<c>Quality = 133</c>, the prior sample's value — never stored at that time) or, before the tag's first sample,
|
||||
/// a NULL row (value 0, Bad). Part 11 Raw with <c>ReturnBounds = false</c> wants stored values only, and the
|
||||
/// server's paging (#669) counts every returned row toward the page and resumes at the last returned timestamp,
|
||||
/// server's paging counts every returned row toward the page and resumes at the last returned timestamp,
|
||||
/// so a fabricated row would also be counted as a sample and re-fabricated at every resume point. The read
|
||||
/// therefore uses the same request start as the raw-evaluated aggregates (#681): it is requested from
|
||||
/// therefore uses the same request start as the raw-evaluated aggregates: it is requested from
|
||||
/// <see cref="RawAggregateReader.RequestStartFor"/> (1 ms early) and only rows
|
||||
/// <see cref="RawAggregateReader.IsWindowSample"/> are kept — the decision is by TIMESTAMP, because a stored NULL
|
||||
/// sample and the no-data boundary row look the same on the wire (both carry a value 0 with a Bad quality). The
|
||||
@@ -184,12 +182,12 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Row budget.</b> <c>0</c> ("all values") asks for <see cref="int.MaxValue"/> (the client package and the
|
||||
/// gateway reject <c>max_values <= 0</c>, #686). A cap asks for <c>cap + </c>
|
||||
/// <see cref="RawAggregateReader.PreWindowAllowance"/> rows — NOT clamped at the gateway's default ceiling (#688
|
||||
/// gateway reject <c>max_values <= 0</c>). A cap asks for <c>cap + </c>
|
||||
/// <see cref="RawAggregateReader.PreWindowAllowance"/> rows — NOT clamped at the gateway's default ceiling (this
|
||||
/// clamps only the per-sample aggregate budget): AVEVA always emits the boundary row, so a clamped budget would make
|
||||
/// every page size within the allowance of the ceiling come back short. A strict gateway
|
||||
/// (<c>AllowStreamingBeyondCeiling=false</c>) refuses an over-ceiling budget and names its ceiling; the read is
|
||||
/// then re-issued ONCE with the ceiling as budget (#691.3, <see cref="GatewayReadLimits"/>) — so an uncapped read
|
||||
/// then re-issued ONCE with the ceiling as budget (<see cref="GatewayReadLimits"/>) — so an uncapped read
|
||||
/// of a small window is served — and a ceiling-bounded read that is not provably complete (it delivered the
|
||||
/// whole ceiling without reaching the window end, or a page short of its cap) fails
|
||||
/// <see cref="HistoryReadLimitExceededException"/> (<c>BadTooManyOperations</c>), never a truncated answer.
|
||||
@@ -216,7 +214,7 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
|
||||
{
|
||||
var all = await PassAsync(int.MaxValue).ConfigureAwait(false);
|
||||
if (all.Result.Complete) return all.Result.Samples;
|
||||
// Only reachable on a strict gateway (#691.3): the ceiling re-read delivered the whole ceiling without reaching
|
||||
// Only reachable on a strict gateway: the ceiling re-read delivered the whole ceiling without reaching
|
||||
// the window end — the window may hold more, and "all values" is never answered from a truncated read.
|
||||
throw new HistoryReadLimitExceededException(cap,
|
||||
$"The raw read of '{fullReference}' holds more than the HistorianGateway's row ceiling ({all.Budget}, its " +
|
||||
@@ -229,8 +227,8 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
|
||||
var pass = await PassAsync(asked).ConfigureAwait(false);
|
||||
if (pass.Result.Complete) return pass.Result.Samples;
|
||||
|
||||
// A strict gateway's ceiling already bounded that read (#691.3): a larger budget would only be refused again. The
|
||||
// refusal carries the pre-window rows it saw (#694): the server re-reads the page that many values smaller.
|
||||
// A strict gateway's ceiling already bounded that read: a larger budget would only be refused again. The
|
||||
// refusal carries the pre-window rows it saw: the server re-reads the page that many values smaller.
|
||||
if (pass.Budget < asked)
|
||||
throw new HistoryReadLimitExceededException(cap,
|
||||
$"The raw read of '{fullReference}' could not be read completely within the HistorianGateway's row ceiling " +
|
||||
@@ -239,16 +237,16 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
|
||||
{ PreWindowRows = Crowding(pass.Result.PreWindowRows) };
|
||||
|
||||
// The margin crowded the window out: re-read with room for the pre-window rows. A read that was itself cut short saw
|
||||
// only some of them (#694 F4: a tie cluster of hundreds just before a resume point used to fail even with no
|
||||
// only some of them (a tie cluster of hundreds just before a resume point used to fail even with no
|
||||
// ceiling), so the room at least doubles per re-read — bounded by MaxCrowdedReReads, and never past a known ceiling.
|
||||
// #694 R2: the room is capped at the tie-cluster limit (MaxTieClusterOverfetch) — a pathological single-millisecond
|
||||
// The room is capped at the tie-cluster limit (MaxTieClusterOverfetch) — a pathological single-millisecond
|
||||
// cluster on a streaming gateway would otherwise be streamed several times over for one page.
|
||||
var preWindow = pass.Result.PreWindowRows;
|
||||
var room = Math.Min(Math.Max(1, preWindow), _maxPreWindowRows);
|
||||
for (var attempt = 0; attempt < MaxCrowdedReReads && preWindow <= _maxPreWindowRows; attempt++)
|
||||
{
|
||||
var budget = (int)Math.Min(int.MaxValue, (long)cap + room + RawAggregateReader.PreWindowAllowance);
|
||||
// #695: doubling can leap over the ceiling while the cluster still fits. Spend the remaining room once.
|
||||
// doubling can leap over the ceiling while the cluster still fits. Spend the remaining room once.
|
||||
if (_ceiling.Learned is var known and > 0 && budget > known)
|
||||
{
|
||||
var available = known - cap - RawAggregateReader.PreWindowAllowance;
|
||||
@@ -270,10 +268,10 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
|
||||
$"the {RawAggregateReader.BoundaryMargin.TotalMilliseconds} ms before the window and exhausted the row budget.")
|
||||
{ PreWindowRows = preWindow };
|
||||
|
||||
// Pre-window rows the allowance did not cover are what crowded the read; fewer are not the cause (#694).
|
||||
// Pre-window rows the allowance did not cover are what crowded the read; fewer are not the cause.
|
||||
static int Crowding(int preWindowRows) => preWindowRows > RawAggregateReader.PreWindowAllowance ? preWindowRows : 0;
|
||||
|
||||
// One drained pass; a strict gateway's refusal of the budget is retried once at the ceiling it names (#691.3).
|
||||
// One drained pass; a strict gateway's refusal of the budget is retried once at the ceiling it names.
|
||||
Task<((List<HistorianSample> Samples, int PreWindowRows, bool Complete) Result, int Budget)> PassAsync(int budget) =>
|
||||
_ceiling.ReadAsync(budget,
|
||||
b => ReadRawPassAsync(fullReference, startUtc, endUtc, cap, b, cancellationToken),
|
||||
@@ -299,7 +297,7 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
|
||||
if (cap == 0 || samples.Count < cap) samples.Add(sample);
|
||||
}
|
||||
// A row before the window — or, for a window starting at/before the epoch (no look-back), AVEVA's synthetic row at
|
||||
// the query start, which is no sample either (#694 F1: it used to set reachedEnd, so a ceiling-truncated read
|
||||
// the query start, which is no sample either (it used to set reachedEnd, so a ceiling-truncated read
|
||||
// passed as complete) — is a pre-window row.
|
||||
else if (sample.Timestamp is { } ts
|
||||
&& (ts.ToDateTime() < startUtc || !RawAggregateReader.IsAfterFloor(ts.ToDateTime()))) preWindow++;
|
||||
@@ -307,32 +305,28 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
|
||||
}
|
||||
|
||||
// cap == 0 ("all values") is complete only when the stream proves it: short of the budget (always, unless a strict
|
||||
// gateway's ceiling became the budget — #691.3) or past the window end.
|
||||
// gateway's ceiling became the budget) or past the window end.
|
||||
var complete = received < budget || reachedEnd || (cap > 0 && samples.Count >= cap);
|
||||
return (samples, preWindow, complete);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Per-sample aggregates (#681)</b> — Average, Count, Minimum, Maximum, Range, Start, End, Delta,
|
||||
/// Minimum/MaximumActualTime — are evaluated from a raw read with the SQL backends' shared evaluator
|
||||
/// (<see cref="RawAggregateReader"/>): identical values, status, Calculated/Partial bits and timestamps to
|
||||
/// SqlServer/QuestDB for the same samples, forward and reverse (#677). The in-window raw rows are capped by
|
||||
/// <c>ServerHistorian:MaxTimeWeightedRawRows</c> (<see cref="HistoryReadLimitExceededException"/>, not counted as
|
||||
/// a backend failure).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>TimeAverage / Total</b> stay native AVEVA retrieval modes (<see cref="AggregateModeMapper"/>), stamped with
|
||||
/// the interval start and the Calculated bit; AVEVA's extra row for the interval before the window is dropped and
|
||||
/// an Uncertain interval keeps its value (#685). Both directions use the SQL backends' interval grid with forward
|
||||
/// requests only — a reverse read (which the gateway refuses) anchored at its later time (#677), the clipped interval
|
||||
/// read over exactly its span and marked Partial (#691.2, <see cref="NativeAggregatePlan"/>). Everything else is <see cref="HistoryAggregateNotSupportedException"/>
|
||||
/// (see <see cref="ServedAggregates"/>).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <exception cref="ArgumentOutOfRangeException">A raw-evaluated read whose window holds more than
|
||||
/// <see cref="BucketPlan.MaxBuckets"/> intervals.</exception>
|
||||
//
|
||||
// Per-sample aggregates — Average, Count, Minimum, Maximum, Range, Start, End, Delta,
|
||||
// Minimum/MaximumActualTime — are evaluated from a raw read with the SQL backends' shared evaluator
|
||||
// (RawAggregateReader): identical values, status, Calculated/Partial bits and timestamps to
|
||||
// SqlServer/QuestDB for the same samples, forward and reverse. The in-window raw rows are capped by
|
||||
// ServerHistorian:MaxTimeWeightedRawRows (HistoryReadLimitExceededException, not counted as
|
||||
// a backend failure).
|
||||
//
|
||||
// TimeAverage / Total stay native AVEVA retrieval modes (AggregateModeMapper), stamped with
|
||||
// the interval start and the Calculated bit; AVEVA's extra row for the interval before the window is dropped and
|
||||
// an Uncertain interval keeps its value. Both directions use the SQL backends' interval grid with forward
|
||||
// requests only — a reverse read (which the gateway refuses) anchored at its later time, the clipped interval
|
||||
// read over exactly its span and marked Partial (NativeAggregatePlan). Everything else is HistoryAggregateNotSupportedException
|
||||
// (see ServedAggregates).
|
||||
//
|
||||
// A raw-evaluated read whose window holds more than BucketPlan.MaxBuckets intervals throws.
|
||||
public async Task<HistoryReadResult> ReadProcessedAsync(
|
||||
string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
|
||||
HistoryAggregateType aggregate, CancellationToken cancellationToken)
|
||||
@@ -342,7 +336,7 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Mapped OUTSIDE the failure-recording try: an aggregate the gateway has no mode for is a client
|
||||
// request the server answers BadAggregateNotSupported, not a backend fault to count (#285).
|
||||
// request the server answers BadAggregateNotSupported, not a backend fault to count.
|
||||
var mode = AggregateModeMapper.ToRetrievalMode(aggregate);
|
||||
// Planned OUTSIDE the failure-recording try: too many intervals is a client request limit, not a backend fault.
|
||||
return await ReadNativeAsync(fullReference, BucketPlan.Create(startUtc, endUtc, interval), mode, cancellationToken)
|
||||
@@ -350,7 +344,7 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A native TimeAverage / Total read on the SQL backends' interval grid, either direction (#691.2): forward native
|
||||
/// A native TimeAverage / Total read on the SQL backends' interval grid, either direction: forward native
|
||||
/// requests only, the clipped interval read over exactly its span. See <see cref="NativeAggregatePlan"/>.
|
||||
/// </summary>
|
||||
private async Task<HistoryReadResult> ReadNativeAsync(string fullReference, BucketPlan plan, RetrievalMode mode,
|
||||
@@ -392,7 +386,7 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Serves a per-sample aggregate from a raw read (#681); see <see cref="RawAggregateReader"/>.</summary>
|
||||
/// <summary>Serves a per-sample aggregate from a raw read; see <see cref="RawAggregateReader"/>.</summary>
|
||||
private async Task<HistoryReadResult> ReadProcessedFromRawAsync(
|
||||
string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
|
||||
HistoryAggregateType aggregate, CancellationToken cancellationToken)
|
||||
@@ -445,31 +439,29 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
|
||||
/// True when <paramref name="ex"/> (or an inner exception — the client package wraps the <see cref="RpcException"/>)
|
||||
/// is the gateway's <c>InvalidArgument</c> "max_values exceeds the server ceiling" rejection.
|
||||
/// </summary>
|
||||
/// <param name="ex">The exception to classify.</param>
|
||||
/// <returns><see langword="true"/> when the exception is the gateway's row-ceiling rejection.</returns>
|
||||
internal static bool IsMaxValuesCeilingRejection(Exception ex) => GatewayReadLimits.IsCeilingRejection(ex);
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// #693: served from RAW gateway reads — each requested time's stored sample at-or-before and after it — evaluated with
|
||||
/// the SQL backends' shared <see cref="AtTimeEvaluator"/> (Part 11 §6.5.4), so the Gateway answers what SqlServer and
|
||||
/// QuestDB answer for the same stored samples; see <see cref="GatewayAtTimeReader"/> for the read plan and its cost.
|
||||
/// AVEVA's own interpolated answer (the gateway's <c>ReadAtTime</c>) returned the previous value as Good at a stored
|
||||
/// NULL's time, null + Bad at a Good sample right after a NULL, a drifted value at an exact stored sample, and no
|
||||
/// Interpolated bit; it is only used when <c>ServerHistorian:GatewayAtTimeFromRaw = false</c> (kill switch).
|
||||
/// A gateway value is a Double (or a String) whatever the node's type (AVEVA stores a Boolean as a 0/1 <c>Int2</c>), so
|
||||
/// without a type hint every numeric tag is interpolated linearly; the node manager passes the node's type through
|
||||
/// <see cref="ReadAtTimeAsync(string, IReadOnlyList{DateTime}, TypeCode?, CancellationToken)"/>.
|
||||
/// </remarks>
|
||||
// served from RAW gateway reads — each requested time's stored sample at-or-before and after it — evaluated with
|
||||
// the SQL backends' shared AtTimeEvaluator (Part 11 §6.5.4), so the Gateway answers what SqlServer and
|
||||
// QuestDB answer for the same stored samples; see GatewayAtTimeReader for the read plan and its cost.
|
||||
// AVEVA's own interpolated answer (the gateway's ReadAtTime) returned the previous value as Good at a stored
|
||||
// NULL's time, null + Bad at a Good sample right after a NULL, a drifted value at an exact stored sample, and no
|
||||
// Interpolated bit; it is only used when ServerHistorian:GatewayAtTimeFromRaw = false (kill switch).
|
||||
// A gateway value is a Double (or a String) whatever the node's type (AVEVA stores a Boolean as a 0/1 Int2), so
|
||||
// without a type hint every numeric tag is interpolated linearly; the node manager passes the node's type through
|
||||
// ReadAtTimeAsync(string, IReadOnlyList<DateTime>, TypeCode?, CancellationToken).
|
||||
public Task<HistoryReadResult> ReadAtTimeAsync(
|
||||
string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken) =>
|
||||
ReadAtTimeAsync(fullReference, timestampsUtc, valueType: null, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// #693: the hint makes the AtTime rules type-exact, as on the SQL backends — the gateway's Double rows are converted to
|
||||
/// the node's type BEFORE the shared evaluator runs (Boolean = value ≠ 0 ⇒ stepped; an integer type ⇒ Int64 ⇒
|
||||
/// interpolated and rounded half away from zero, then returned as the node's integer type; String ⇒ stepped; Float /
|
||||
/// Double / no hint ⇒ linear Double). Ignored with the kill switch (AVEVA's own answer).
|
||||
/// </remarks>
|
||||
// the hint makes the AtTime rules type-exact, as on the SQL backends — the gateway's Double rows are converted to
|
||||
// the node's type BEFORE the shared evaluator runs (Boolean = value ≠ 0 ⇒ stepped; an integer type ⇒ Int64 ⇒
|
||||
// interpolated and rounded half away from zero, then returned as the node's integer type; String ⇒ stepped; Float /
|
||||
// Double / no hint ⇒ linear Double). Ignored with the kill switch (AVEVA's own answer).
|
||||
public async Task<HistoryReadResult> ReadAtTimeAsync(
|
||||
string fullReference, IReadOnlyList<DateTime> timestampsUtc, TypeCode? valueType, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -505,7 +497,7 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The pre-#693 AtTime: AVEVA's own interpolation, aligned to the request (kill switch only).</summary>
|
||||
/// <summary>The previous AtTime: AVEVA's own interpolation, aligned to the request (kill switch only).</summary>
|
||||
private async Task<HistoryReadResult> ReadAtTimeNativeAsync(
|
||||
string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -533,42 +525,34 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// <c>false</c> (Gitea #686): the gateway's <c>ReadRaw</c> streams ascending only and rejects <c>end < start</c>
|
||||
/// (<c>InvalidArgument</c>). The server serves reverse and end-only capped raw reads from bounded forward reads
|
||||
/// instead, and never sends this source a reverse window (<see cref="ReadRawAsync"/> refuses one unsent).
|
||||
/// </remarks>
|
||||
// false: the gateway's ReadRaw streams ascending only and rejects end < start
|
||||
// (InvalidArgument). The server serves reverse and end-only capped raw reads from bounded forward reads
|
||||
// instead, and never sends this source a reverse window (ReadRawAsync refuses one unsent).
|
||||
public bool SupportsReverseRaw => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Gitea #694: <c>0</c> until a strict gateway (<c>AllowStreamingBeyondCeiling=false</c>) has refused a budget and
|
||||
/// named its ceiling (#691.3, <see cref="GatewayRowCeiling"/>); then that ceiling less the
|
||||
/// <see cref="RawAggregateReader.PreWindowAllowance"/> a capped read adds for AVEVA's boundary row — so a page of this
|
||||
/// size asks for exactly the ceiling. Back to <c>0</c> when a re-probe finds the gateway streaming again.
|
||||
/// </remarks>
|
||||
// 0 until a strict gateway (AllowStreamingBeyondCeiling=false) has refused a budget and
|
||||
// named its ceiling (GatewayRowCeiling); then that ceiling less the
|
||||
// RawAggregateReader.PreWindowAllowance a capped read adds for AVEVA's boundary row — so a page of this
|
||||
// size asks for exactly the ceiling. Back to 0 when a re-probe finds the gateway streaming again.
|
||||
public int MaxRawValuesPerRead => _ceiling.Learned is var c and > 0
|
||||
? Math.Max(1, c - RawAggregateReader.PreWindowAllowance)
|
||||
: 0;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// <c>false</c>: the gateway's <c>ReadEvents</c> streams ascending only (Gitea #675). The server never sends it a
|
||||
/// reverse read; since #691.4 it walks one back from bounded forward reads (ties sorted by EventId — the gateway
|
||||
/// orders by event time only).
|
||||
/// </remarks>
|
||||
// false: the gateway's ReadEvents streams ascending only. The server never sends it a
|
||||
// reverse read; it walks one back from bounded forward reads (ties sorted by EventId — the gateway
|
||||
// orders by event time only).
|
||||
public bool SupportsReverseEvents => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks><see cref="ServedAggregates"/>; every other member answers <c>BadAggregateNotSupported</c> and is not advertised.</remarks>
|
||||
// ServedAggregates; every other member answers BadAggregateNotSupported and is not advertised.
|
||||
public IReadOnlySet<HistoryAggregateType> SupportedAggregates => ServedAggregates;
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// A reverse (newest-first) read is refused with <see cref="NotSupportedException"/> (Gitea #675) — the gateway's
|
||||
/// <c>ReadEvents</c> streams ascending only and rejects <c>end < start</c>. The server never calls this with
|
||||
/// <c>reverse</c>: it walks a reverse read back from bounded forward reads (#691.4). Nothing is sent.
|
||||
/// </remarks>
|
||||
// A reverse (newest-first) read is refused with NotSupportedException — the gateway's
|
||||
// ReadEvents streams ascending only and rejects end < start. The server never calls this with
|
||||
// reverse: it walks a reverse read back from bounded forward reads. Nothing is sent.
|
||||
public Task<HistoricalEventsResult> ReadEventsAsync(
|
||||
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, bool reverse,
|
||||
CancellationToken cancellationToken) =>
|
||||
@@ -585,7 +569,7 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
|
||||
try
|
||||
{
|
||||
var hasCap = maxEvents > 0;
|
||||
// Bounded initial capacity: an uncapped read arrives as int.MaxValue ("Array dimensions exceeded", #691.4 review).
|
||||
// Bounded initial capacity: an uncapped read arrives as int.MaxValue ("Array dimensions exceeded").
|
||||
var collected = new List<HistoricalEvent>(hasCap ? Math.Min(maxEvents, 1024) : 0);
|
||||
var truncated = false;
|
||||
|
||||
@@ -702,7 +686,7 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
|
||||
private static HistoryReadResult MissingAtTime(IReadOnlyList<DateTime> times, DateTime now) =>
|
||||
new(times.Select(t => AtTimeEvaluator.Evaluate(t, null, null, now, now)).ToArray(), ContinuationPoint: null);
|
||||
|
||||
/// <summary>Classifies actual gRPC/package transport failures without treating refusals as outages (#697).</summary>
|
||||
/// <summary>Classifies actual gRPC/package transport failures without treating refusals as outages.</summary>
|
||||
private static bool IsTransportFailure(Exception ex) => ex switch
|
||||
{
|
||||
ZB.MOM.WW.HistorianGateway.Client.HistorianGatewayUnavailableException => true,
|
||||
|
||||
@@ -26,11 +26,9 @@ public sealed class GatewayHistorianProvider : IHistorianBackendProvider
|
||||
/// <inheritdoc />
|
||||
public string Name => "Gateway";
|
||||
|
||||
// The recorder's own default set (ContinuousHistorizationRecorder.DefaultSupportedKinds), so the
|
||||
// gateway's kinds and the recorder's fallback can never drift apart.
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// The recorder's own default set (<see cref="ContinuousHistorizationRecorder.DefaultSupportedKinds"/>), so the
|
||||
/// gateway's kinds and the recorder's fallback can never drift apart.
|
||||
/// </remarks>
|
||||
public IReadOnlySet<HistorianValueKind> SupportedKinds => ContinuousHistorizationRecorder.DefaultSupportedKinds;
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
+3
-3
@@ -39,11 +39,11 @@ public static class GatewayHistorian
|
||||
return new GatewayHistorianDataSource(
|
||||
HistorianGatewayClientAdapter.Create(options, loggerFactory),
|
||||
logger,
|
||||
// #681: the raw-evaluated per-sample aggregates share the SQL backends' raw-row cap.
|
||||
// The raw-evaluated per-sample aggregates share the SQL backends' raw-row cap.
|
||||
maxRawRows: options.MaxTimeWeightedRawRows,
|
||||
// #693: AtTime from raw reads + the shared evaluator; false = AVEVA's own interpolation (kill switch).
|
||||
// AtTime from raw reads + the shared evaluator; false = AVEVA's own interpolation (kill switch).
|
||||
atTimeFromRaw: options.GatewayAtTimeFromRaw,
|
||||
// #694 R2: a crowding re-read never makes room for more pre-window rows than the tie-cluster limit.
|
||||
// A crowding re-read never makes room for more pre-window rows than the tie-cluster limit.
|
||||
maxPreWindowRows: options.MaxTieClusterOverfetch);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway;
|
||||
/// from this side of the wire. With <c>AllowStreamingBeyondCeiling=false</c> (a "strict" gateway) the gateway refuses
|
||||
/// any <c>ReadRaw</c> whose <c>max_values</c> exceeds the ceiling with <c>InvalidArgument</c> — BEFORE it streams a row
|
||||
/// — and names the ceiling in the refusal (<c>"max_values exceeds the server ceiling of {n}; …"</c>). The gateway has
|
||||
/// no RPC that reports the ceiling, so the refusal is how it is learned (#691.3).
|
||||
/// no RPC that reports the ceiling, so the refusal is how it is learned.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A read whose budget the gateway refuses is re-issued ONCE with the ceiling as its budget, and the ceiling is
|
||||
@@ -60,7 +60,7 @@ internal static class GatewayReadLimits
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A strict gateway's row ceiling as learned by ONE data source (#691.3 review): every refused attempt counts as an
|
||||
/// A strict gateway's row ceiling as learned by ONE data source: every refused attempt counts as an
|
||||
/// error in the gateway's own outcome metrics, so the ceiling a refusal names is remembered and later reads ask for at
|
||||
/// most it — no refusal per read in the steady state. Thread-safe (one <see cref="int"/>, read/written atomically; a
|
||||
/// race costs at most one extra refusal or re-probe, never a wrong answer).
|
||||
@@ -76,7 +76,7 @@ internal static class GatewayReadLimits
|
||||
/// the read is re-issued at <c>N</c>; refused naming <c>C</c> again ⇒ the <c>C</c>-bounded result stands (no repeat).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Re-probe timing (#696).</b> Raw and AtTime reads re-probe incomplete streams immediately. Per-sample
|
||||
/// <b>Re-probe timing.</b> Raw and AtTime reads re-probe incomplete streams immediately. Per-sample
|
||||
/// aggregate reads opt into a 30-second cooldown after a refusal; a full stream during it remains a request
|
||||
/// limit, never a truncated answer. After the cooldown the next incomplete read detects a raised ceiling.
|
||||
/// </para>
|
||||
@@ -105,7 +105,7 @@ internal sealed class GatewayRowCeiling(TimeProvider? timeProvider = null)
|
||||
var bounded = await AttemptAsync(learned, pass).ConfigureAwait(false);
|
||||
if (bounded.Budget < learned || !needsMore(bounded.Result, bounded.Budget)) return bounded;
|
||||
|
||||
// #696: aggregate reads may reuse a recent refusal for 30 seconds. Raw paging keeps immediate re-probes.
|
||||
// Aggregate reads may reuse a recent refusal for 30 seconds. Raw paging keeps immediate re-probes.
|
||||
if (deferRecentReprobe && _time.GetUtcNow().UtcTicks - Volatile.Read(ref _lastRefusalTicks) < TimeSpan.FromSeconds(30).Ticks)
|
||||
return bounded;
|
||||
|
||||
|
||||
@@ -2,13 +2,17 @@ using Grpc.Core;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway;
|
||||
|
||||
/// <summary>Bounded metadata cache (#653). Missing tags expire quickly; failures never become missing tags.</summary>
|
||||
/// <summary>Bounded metadata cache. Missing tags expire quickly; failures never become missing tags.</summary>
|
||||
internal sealed class GatewayTagPresenceCache(IHistorianGatewayClient client, TimeProvider time)
|
||||
{
|
||||
internal const int Capacity = 4096;
|
||||
private readonly object _sync = new();
|
||||
private readonly Dictionary<string, (bool Exists, DateTimeOffset Expires)> _entries = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>Reports whether a tag exists in the historian, using the cache when the entry is fresh.</summary>
|
||||
/// <param name="tag">The historian tag name.</param>
|
||||
/// <param name="ct">Cancels the lookup.</param>
|
||||
/// <returns><see langword="true"/> when the tag exists.</returns>
|
||||
public async Task<bool> ExistsAsync(string tag, CancellationToken ct)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
@@ -30,7 +30,7 @@ public sealed class GatewayTagProvisioner : IHistorianProvisioning, IAsyncDispos
|
||||
private const uint DefaultStorageRateMs = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Integral divisor stamped on every provisioned analog tag (#677). AVEVA's <c>Total</c> integral is
|
||||
/// Integral divisor stamped on every provisioned analog tag. AVEVA's <c>Total</c> integral is
|
||||
/// <c>∫ value dt (seconds) ÷ IntegralDivisor</c>; the proto's <c>double</c> default of 0 would reach the gateway as
|
||||
/// "unset" (or a divide-by-zero), so 1 is sent explicitly — the value·seconds unit the SQL backends' <c>Total</c>
|
||||
/// uses. (The live magnitude check against AVEVA is still open: it needs the VPN.)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user