Merge lane G1: OpcUaClient + MTConnect driver remediation (#743)

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 17:16:20 -04:00
51 changed files with 4594 additions and 629 deletions
@@ -44,6 +44,13 @@ public sealed class MTConnectDriverOptions
/// The <c>count</c> query parameter passed to the Agent's <c>/sample</c> request — the
/// maximum number of DataItem observations returned per chunk. Default <c>1000</c>, the
/// MTConnect Agent's own conventional default.
/// <para>
/// <b>It caps subscription throughput</b> at <c>SampleCount / SampleIntervalMs</c> observations per
/// millisecond (default 1000 a second): a machine producing more falls behind the Agent's ring buffer. The
/// pump measures that lag and, when it is persistently behind, reopens the stream with the count doubled — up
/// to 16× this value, at most 65 536 — and reports the effective count and the lag on the <c>/hosts</c>
/// diagnostics (review 2026-10-21 T-5). Each new stream starts again from this value.
/// </para>
/// </summary>
public int SampleCount { get; init; } = 1000;
@@ -26,27 +26,17 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// </param>
/// <param name="MtCategory">
/// The DataItem's MTConnect <c>category</c> attribute — <c>SAMPLE</c>, <c>EVENT</c>, or
/// <c>CONDITION</c>. Optional metadata carried through for browse / display; not consulted by
/// the value-mapping path. Default <c>null</c>.
/// </param>
/// <param name="MtType">
/// The DataItem's MTConnect <c>type</c> attribute (e.g. <c>POSITION</c>, <c>EXECUTION</c>).
/// Optional metadata. Default <c>null</c>.
/// </param>
/// <param name="MtSubType">
/// The DataItem's MTConnect <c>subType</c> attribute (e.g. <c>ACTUAL</c>, <c>COMMANDED</c>).
/// Optional metadata. Default <c>null</c>.
/// </param>
/// <param name="Units">
/// The DataItem's MTConnect <c>units</c> attribute (e.g. <c>MILLIMETER</c>,
/// <c>REVOLUTION/MINUTE</c>). Optional metadata. Default <c>null</c>.
/// <c>CONDITION</c>. Read by the write path (a CONDITION is never writable). Default <c>null</c>.
/// </param>
/// <remarks>
/// Review 2026-10-21 T-3: the <c>MtType</c> / <c>MtSubType</c> / <c>Units</c> members were carried here and
/// read by nothing — deleted. A stored blob or <c>tags[]</c> entry that still carries <c>mtType</c> /
/// <c>mtSubType</c> / <c>units</c> keeps loading (unknown JSON keys are skipped); the AdminUI tag editor reads
/// those keys straight off the blob for its display and type-inference advisory, not through this record.
/// </remarks>
public sealed record MTConnectTagDefinition(
string FullName,
DriverDataType DriverDataType,
bool IsArray = false,
int ArrayDim = 0,
string? MtCategory = null,
string? MtType = null,
string? MtSubType = null,
string? Units = null);
string? MtCategory = null);
@@ -87,10 +87,7 @@ public static class MTConnectTagDefinitionFactory
parsed,
blob.IsArray ?? false,
blob.ArrayDim ?? 0,
blob.MtCategory,
blob.MtType,
blob.MtSubType,
blob.Units);
blob.MtCategory);
return true;
}
@@ -264,11 +261,5 @@ public static class MTConnectTagDefinitionFactory
public int? ArrayDim { get; init; }
/// <summary>The MTConnect DataItem category.</summary>
public string? MtCategory { get; init; }
/// <summary>The MTConnect DataItem type.</summary>
public string? MtType { get; init; }
/// <summary>The MTConnect DataItem sub-type.</summary>
public string? MtSubType { get; init; }
/// <summary>The engineering units, when authored.</summary>
public string? Units { get; init; }
}
}
@@ -59,7 +59,7 @@ public sealed record MTConnectWriteOutcome(bool Accepted, string? AgentError);
/// <see cref="CancellationTokenSource"/>. An <see cref="IAsyncDisposable"/> here would be a
/// synchronous method wearing a <c>ValueTask</c>, and it would oblige every canned-XML fake
/// to grow one too. The <b>async</b> unwind that a streaming client does need is already
/// covered elsewhere and is not this method's job: the <see cref="SampleAsync"/> enumerator
/// covered elsewhere and is not this method's job: the <see cref="SampleAsync(long, CancellationToken)"/> enumerator
/// is itself <see cref="IAsyncDisposable"/> and is owned by the pump that enumerates it,
/// while <see cref="IDisposable.Dispose"/> on the client converts any read still in flight
/// into an ordinary <see cref="OperationCanceledException"/>.
@@ -76,10 +76,26 @@ public interface IMTConnectAgentClient : IDisposable
/// <returns>The parsed device hierarchy.</returns>
Task<MTConnectProbeModel> ProbeAsync(CancellationToken ct);
/// <summary>
/// A connectivity check: does the Agent answer <c>/probe</c> with a success status? Reads the response
/// headers only — the device model is neither downloaded in full nor parsed (R06:T-7: the connectivity
/// probe used to parse the whole <c>/probe</c> document every tick and throw it away). Throws exactly when
/// <see cref="ProbeAsync"/> would for an unreachable or refusing Agent. The default delegates to
/// <see cref="ProbeAsync"/> and discards the model.
/// </summary>
/// <remarks>
/// Trade-off (review m5): a success status from something that is NOT an Agent — a proxy serving an HTML page
/// under 200 — now reads as reachable, where <see cref="ProbeAsync"/> would have failed to parse it. The data
/// paths still report that misconfiguration (the read / stream fail to parse).
/// </remarks>
/// <param name="ct">Cancellation token (the caller's deadline).</param>
/// <returns>A task completing when the Agent has answered with a success status.</returns>
async Task PingAsync(CancellationToken ct) => _ = await ProbeAsync(ct).ConfigureAwait(false);
/// <summary>
/// Issues an Agent <c>/current</c> request and returns the latest observed value for every
/// data item. Used both to prime the observation index at startup and to re-baseline after
/// a detected sequence gap in <see cref="SampleAsync"/>.
/// a detected sequence gap in <see cref="SampleAsync(long, CancellationToken)"/>.
/// </summary>
/// <param name="ct">Cancellation token.</param>
/// <returns>The latest observation for every data item.</returns>
@@ -113,6 +129,20 @@ public interface IMTConnectAgentClient : IDisposable
/// <returns>An asynchronous sequence of streams results, one per multipart chunk.</returns>
IAsyncEnumerable<MTConnectStreamsResult> SampleAsync(long from, CancellationToken ct);
/// <summary>
/// As <see cref="SampleAsync(long, CancellationToken)"/>, but asking the Agent for up to
/// <paramref name="count"/> observations per chunk instead of the configured
/// <see cref="MTConnectDriverOptions.SampleCount"/> — how the pump catches up when it falls behind an Agent
/// producing more than <c>SampleCount / SampleIntervalMs</c> observations a second (R06:T-5). The default
/// ignores <paramref name="count"/>, so a client that cannot honour it keeps working at the configured one.
/// </summary>
/// <param name="from">The sequence to open the stream at.</param>
/// <param name="count">The per-chunk observation cap for this stream.</param>
/// <param name="ct">Cancellation token; the only contracted way the enumeration ends quietly.</param>
/// <returns>The stream's chunks.</returns>
IAsyncEnumerable<MTConnectStreamsResult> SampleAsync(long from, int count, CancellationToken ct) =>
SampleAsync(from, ct);
/// <summary>
/// Sets the Agent's observation for one DataItem, through the Agent's <c>AllowPut</c>
/// surface (<c>POST {agent}/{deviceName}?{dataItemId}={value}</c>).
@@ -174,6 +204,34 @@ public interface IMTConnectAgentClient : IDisposable
Task<MTConnectWriteOutcome> WriteObservationAsync(
string deviceName, string dataItemId, string value, CancellationToken cancellationToken);
/// <summary>
/// Sets several observations on ONE device in one request — the Agent's PUT surface takes any number of
/// <c>{dataItemId}={value}</c> query pairs (<c>POST {agent}/{device}?a=1&amp;b=2</c>) — so a multi-write
/// batch costs one round trip per device instead of one per slot (R06:T-6). Same answer contract as
/// <see cref="WriteObservationAsync"/>: one outcome for the whole request, an Agent refusal is a value, an
/// unreachable Agent throws. The ids must be distinct (a repeated query key's meaning is the Agent's choice).
/// The default issues the single-observation call per pair and reports the first refusal.
/// </summary>
/// <param name="deviceName">The device that owns every DataItem in <paramref name="observations"/>.</param>
/// <param name="observations">The <c>(dataItemId, value)</c> pairs, unescaped, ids distinct.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>What the Agent said about the request.</returns>
async Task<MTConnectWriteOutcome> WriteObservationsAsync(
string deviceName, IReadOnlyList<KeyValuePair<string, string>> observations, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(observations);
foreach (var (dataItemId, value) in observations)
{
var outcome = await WriteObservationAsync(deviceName, dataItemId, value, cancellationToken).ConfigureAwait(false);
if (!outcome.Accepted)
{
return outcome;
}
}
return new MTConnectWriteOutcome(Accepted: true, AgentError: null);
}
/// <summary>
/// Has the Agent's ring buffer overflowed past the sequence the caller expected next? True
/// when the chunk's oldest retained sequence is <i>strictly</i> newer than
@@ -183,7 +241,7 @@ public interface IMTConnectAgentClient : IDisposable
/// <remarks>
/// <para>
/// <b><paramref name="expectedFrom"/> is a running cursor, not the original
/// <c>from</c>.</b> It equals the <c>from</c> passed to <see cref="SampleAsync"/> only
/// <c>from</c>.</b> It equals the <c>from</c> passed to <see cref="SampleAsync(long, CancellationToken)"/> only
/// for the <i>first</i> chunk; from the second chunk on it is the <b>previous</b>
/// chunk's <see cref="MTConnectStreamsResult.NextSequence"/>. Comparing every chunk
/// against the original <c>from</c> instead reports a gap on every chunk once the
@@ -199,7 +257,7 @@ public interface IMTConnectAgentClient : IDisposable
/// a chunk the Agent was willing to send — and cppagent answers a <c>from</c> that has
/// already fallen out of its buffer with an <c>MTConnectError</c> / <c>OUT_OF_RANGE</c>
/// document served under <b>HTTP 200</b>, which this client surfaces as an
/// <see cref="InvalidDataException"/> out of <see cref="SampleAsync"/>, never as a chunk.
/// <see cref="InvalidDataException"/> out of <see cref="SampleAsync(long, CancellationToken)"/>, never as a chunk.
/// A pump that re-baselines only on <c>IsSequenceGap</c> will therefore fault instead of
/// recovering exactly when it has fallen furthest behind. Treat an <c>OUT_OF_RANGE</c>
/// parse failure as a re-baseline trigger (re-prime from <see cref="CurrentAsync"/>,
@@ -17,7 +17,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <b>The constructor opens no connection.</b> It only composes the request URIs and builds
/// its <see cref="HttpClient"/>s (which themselves dial nothing until a request is issued),
/// so a throwaway instance is safe to construct — the universal discovery browser's
/// <c>CanBrowse</c> pattern depends on that. <see cref="SampleAsync"/> likewise dials
/// <c>CanBrowse</c> pattern depends on that. <see cref="SampleAsync(long, CancellationToken)"/> likewise dials
/// nothing until it is enumerated.
/// </para>
/// <para>
@@ -54,7 +54,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// </para>
/// <para>
/// <b>Disposal is safe against an in-flight enumeration.</b> Re-initialization disposes and rebuilds this client, which can happen while a pump is still
/// enumerating <see cref="SampleAsync"/>. Rather than letting the in-flight read fault with
/// enumerating <see cref="SampleAsync(long, CancellationToken)"/>. Rather than letting the in-flight read fault with
/// an unclassified <see cref="ObjectDisposedException"/> or <see cref="IOException"/> that no
/// caller is written to expect, every request runs under a token linked to an internal
/// dispose token, so disposal surfaces as an ordinary
@@ -79,6 +79,8 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
private readonly string _agentRoot;
private readonly string _sampleUriBase;
private readonly string _sampleQuerySuffix;
private readonly int _sampleCount;
private readonly int _heartbeatMs;
private readonly TimeSpan _requestTimeout;
private readonly TimeSpan _streamIdleTimeout;
@@ -151,9 +153,10 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
_probeUri = BuildRequestUri(options, "probe");
_currentUri = BuildRequestUri(options, "current");
_sampleUriBase = BuildRequestUri(options, "sample").AbsoluteUri;
_sampleQuerySuffix = string.Create(
CultureInfo.InvariantCulture,
$"&interval={options.SampleIntervalMs}&count={options.SampleCount}&heartbeat={options.HeartbeatMs}");
// count is appended per request: the driver may raise it for one stream when it falls behind (R06:T-5).
_sampleQuerySuffix = string.Create(CultureInfo.InvariantCulture, $"&interval={options.SampleIntervalMs}");
_sampleCount = options.SampleCount;
_heartbeatMs = options.HeartbeatMs;
_http = handler is null ? new HttpClient() : new HttpClient(handler, disposeHandler: false);
_http.Timeout = _requestTimeout;
@@ -179,6 +182,35 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
return MTConnectProbeParser.Parse(body);
}
/// <inheritdoc/>
public async Task PingAsync(CancellationToken ct)
{
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);
using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(ct, _disposeCts.Token);
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);
deadline.CancelAfter(_requestTimeout);
using var request = new HttpRequestMessage(HttpMethod.Get, _probeUri);
HttpResponseMessage response;
try
{
// ResponseHeadersRead: the status line is the whole answer this needs. The body (the device model —
// the largest document the Agent serves) is never buffered or parsed; disposing the response drops it.
response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, deadline.Token)
.ConfigureAwait(false);
}
catch (OperationCanceledException) when (!lifetime.IsCancellationRequested)
{
throw TimedOut(_probeUri);
}
using (response)
{
response.EnsureSuccessStatusCode();
}
}
/// <inheritdoc/>
public async Task<MTConnectStreamsResult> CurrentAsync(CancellationToken ct)
{
@@ -195,10 +227,32 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
public async IAsyncEnumerable<MTConnectStreamsResult> SampleAsync(
long from,
[EnumeratorCancellation] CancellationToken ct)
{
await foreach (var chunk in SampleAsync(from, count: null, ct).ConfigureAwait(false))
{
yield return chunk;
}
}
/// <inheritdoc/>
public IAsyncEnumerable<MTConnectStreamsResult> SampleAsync(long from, int count, CancellationToken ct)
{
if (count <= 0)
{
throw new ArgumentOutOfRangeException(nameof(count), count, "a /sample count must be positive");
}
return SampleAsync(from, (int?)count, ct);
}
private async IAsyncEnumerable<MTConnectStreamsResult> SampleAsync(
long from,
int? count,
[EnumeratorCancellation] CancellationToken ct)
{
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);
var uri = BuildSampleUri(from);
var uri = BuildSampleUri(from, count);
// Linked to the caller's token AND to disposal, so tearing this client down mid-enumeration
// surfaces as a cancellation rather than as a raw ObjectDisposedException from the socket.
@@ -226,7 +280,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
_ = MTConnectStreamsParser.Parse(single);
throw new MTConnectStreamNotSupportedException(
$"MTConnect Agent answered the /sample request '{uri}' with a single '{response.Content.Headers.ContentType?.MediaType ?? "unknown"}' document instead of a multipart/x-mixed-replace stream. It parsed as a valid MTConnectStreams document but will never stream, so no subscription is possible; check that the URI addresses an MTConnect Agent directly and is not fronted by a proxy that buffers streaming responses.");
$"MTConnect Agent answered the /sample request '{MTConnectUriRedaction.Redact(uri)}' with a single '{response.Content.Headers.ContentType?.MediaType ?? "unknown"}' document instead of a multipart/x-mixed-replace stream. It parsed as a valid MTConnectStreams document but will never stream, so no subscription is possible; check that the URI addresses an MTConnect Agent directly and is not fronted by a proxy that buffers streaming responses.");
}
var reader = new MultipartStreamReader(body, boundary, _streamIdleTimeout, uri, _logger);
@@ -239,7 +293,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
// The caller cancelling is the ONE contracted way out; anything else is reported.
live.ThrowIfCancellationRequested();
throw new MTConnectStreamEndedException(reader.EndReason, uri.AbsoluteUri, delivered);
throw new MTConnectStreamEndedException(reader.EndReason, MTConnectUriRedaction.Redact(uri), delivered);
}
// A frame carrying nothing but whitespace is transport filler, not a document. The
@@ -258,8 +312,18 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
}
/// <inheritdoc/>
public async Task<MTConnectWriteOutcome> WriteObservationAsync(
public Task<MTConnectWriteOutcome> WriteObservationAsync(
string deviceName, string dataItemId, string value, CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(dataItemId);
ArgumentNullException.ThrowIfNull(value);
return WriteObservationsAsync(deviceName, [new KeyValuePair<string, string>(dataItemId, value)], cancellationToken);
}
/// <inheritdoc/>
public async Task<MTConnectWriteOutcome> WriteObservationsAsync(
string deviceName, IReadOnlyList<KeyValuePair<string, string>> observations, CancellationToken cancellationToken)
{
// Issued on _http, the unary client — the write is a short request/response
// and must be bounded by RequestTimeoutMs like the other unary legs. Sending it on
@@ -268,10 +332,19 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
// watchdog protects a multipart body, not a one-shot POST).
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);
ArgumentException.ThrowIfNullOrWhiteSpace(deviceName);
ArgumentException.ThrowIfNullOrWhiteSpace(dataItemId);
ArgumentNullException.ThrowIfNull(value);
ArgumentNullException.ThrowIfNull(observations);
if (observations.Count == 0)
{
throw new ArgumentException("at least one observation is required", nameof(observations));
}
var uri = BuildWriteUri(deviceName, dataItemId, value);
foreach (var (dataItemId, value) in observations)
{
ArgumentException.ThrowIfNullOrWhiteSpace(dataItemId, nameof(observations));
ArgumentNullException.ThrowIfNull(value, nameof(observations));
}
var uri = BuildWriteUri(deviceName, observations);
using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _disposeCts.Token);
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);
@@ -326,10 +399,11 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
/// as a query pair. Every caller-supplied part is percent-encoded here — see the seam's
/// contract for why escaping cannot be the caller's job.
/// </summary>
private Uri BuildWriteUri(string deviceName, string dataItemId, string value)
private Uri BuildWriteUri(string deviceName, IReadOnlyList<KeyValuePair<string, string>> observations)
{
var device = Uri.EscapeDataString(deviceName.Trim());
var query = $"{Uri.EscapeDataString(dataItemId)}={Uri.EscapeDataString(value)}";
var query = string.Join(
"&", observations.Select(o => $"{Uri.EscapeDataString(o.Key)}={Uri.EscapeDataString(o.Value)}"));
return new Uri($"{_agentRoot}/{device}?{query}", UriKind.Absolute);
}
@@ -411,8 +485,10 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
}
}
private Uri BuildSampleUri(long from) =>
new(string.Create(CultureInfo.InvariantCulture, $"{_sampleUriBase}?from={from}{_sampleQuerySuffix}"),
private Uri BuildSampleUri(long from, int? count) =>
new(string.Create(
CultureInfo.InvariantCulture,
$"{_sampleUriBase}?from={from}{_sampleQuerySuffix}&count={count ?? _sampleCount}&heartbeat={_heartbeatMs}"),
UriKind.Absolute);
private async Task<HttpResponseMessage> SendAsync(Uri uri, CancellationToken ct)
@@ -484,7 +560,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
}
private TimeoutException TimedOut(Uri uri) =>
new($"MTConnect Agent request to '{uri}' did not complete within {_requestTimeout.TotalMilliseconds:F0} ms.");
new($"MTConnect Agent request to '{MTConnectUriRedaction.Redact(uri)}' did not complete within {_requestTimeout.TotalMilliseconds:F0} ms.");
/// <summary>
/// The <c>boundary</c> parameter of a <c>multipart/*</c> response, or <c>null</c> when the
@@ -513,7 +589,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
if (string.IsNullOrEmpty(boundary))
{
throw new MTConnectStreamNotSupportedException(
$"MTConnect Agent answered the /sample request '{uri}' with Content-Type '{contentType.MediaType}' but no 'boundary' parameter, so the multipart stream cannot be framed.");
$"MTConnect Agent answered the /sample request '{MTConnectUriRedaction.Redact(uri)}' with Content-Type '{contentType.MediaType}' but no 'boundary' parameter, so the multipart stream cannot be framed.");
}
return boundary;
@@ -556,7 +632,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
(parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps))
{
throw new ArgumentException(
$"{nameof(MTConnectDriverOptions.AgentUri)} '{agentUri}' is not an absolute http(s) URI.", nameof(options));
$"{nameof(MTConnectDriverOptions.AgentUri)} '{MTConnectUriRedaction.Redact(agentUri)}' is not an absolute http(s) URI.", nameof(options));
}
return parsed.GetLeftPart(UriPartial.Path).TrimEnd('/');
@@ -692,7 +768,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
logger?.LogWarning(
"MTConnect /sample stream from '{AgentUri}' sends parts with no Content-length header. Falling back to boundary-scan framing, which cannot emit a chunk until the NEXT chunk begins, so every observation is delayed by up to one Agent heartbeat. Values will read stale; this is the Agent's framing, not a driver fault.",
uri);
MTConnectUriRedaction.Redact(uri));
}
private byte[]? Finish(MTConnectStreamEndReason reason)
@@ -861,7 +937,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
if (_buffer.Length >= MaxBufferBytes)
{
throw new InvalidDataException(
$"MTConnect /sample stream from '{uri}' sent a part larger than {MaxBufferBytes} bytes without a boundary; refusing to buffer further.");
$"MTConnect /sample stream from '{MTConnectUriRedaction.Redact(uri)}' sent a part larger than {MaxBufferBytes} bytes without a boundary; refusing to buffer further.");
}
Array.Resize(ref _buffer, Math.Min(_buffer.Length * 2, MaxBufferBytes));
@@ -878,7 +954,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
throw new TimeoutException(
$"MTConnect /sample stream from '{uri}' sent no data (not even a heartbeat) within {idleTimeout.TotalMilliseconds:F0} ms; treating the Agent as unreachable rather than waiting indefinitely.");
$"MTConnect /sample stream from '{MTConnectUriRedaction.Redact(uri)}' sent no data (not even a heartbeat) within {idleTimeout.TotalMilliseconds:F0} ms; treating the Agent as unreachable rather than waiting indefinitely.");
}
if (read <= 0)
@@ -0,0 +1,156 @@
using System.Globalization;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <summary>
/// Review 2026-10-21 R06:T-5 — <c>/sample</c> throughput is capped at
/// <see cref="MTConnectDriverOptions.SampleCount"/> observations per chunk, one chunk per
/// <see cref="MTConnectDriverOptions.SampleIntervalMs"/>. A machine producing more than that falls steadily behind
/// the Agent's ring buffer: every buffer-length the driver re-baselines (losing every intermediate transition) and
/// every delivered value is backlog-stale. This half measures the lag, surfaces it, and raises the per-stream
/// <c>count</c> so the pump can catch up.
/// </summary>
/// <remarks>
/// <para>
/// <b>Measured per chunk.</b> <c>lag = lastSequence + 1 − nextSequence</c> — the observations the Agent holds
/// that this stream has not been sent yet (unknown when the Agent omits <c>lastSequence</c>). A chunk is
/// <b>saturated</b> when it carries the full <c>count</c>: the Agent had more and stopped. A stream is behind
/// when <see cref="BehindChunksBeforeRaise"/> consecutive chunks are saturated with a positive (or unknown) lag.
/// </para>
/// <para>
/// <b>Adapted per pump.</b> A behind stream is reopened at the cursor it reached with <c>count</c> doubled, up
/// to <see cref="SampleCountCeiling"/>. A raised stream that then delivers nothing (an Agent that refuses the
/// larger count) steps back to the last count that delivered and stops raising for that pump. At the ceiling,
/// a stream still behind logs one Warning per pump. A new pump (a re-initialize, a resubscribe) starts again
/// from the configured <see cref="MTConnectDriverOptions.SampleCount"/> — the count is an upper bound, so a
/// raised one costs nothing while the Agent has little to send.
/// </para>
/// <para>
/// <b>Surfaced</b> on the <see cref="IDriverDiagnostics"/> bag the <c>/hosts</c> page shows: the effective
/// count, the last measured lag, saturated chunks and count raises.
/// </para>
/// </remarks>
public sealed partial class MTConnectDriver : IDriverDiagnostics
{
/// <summary>Consecutive behind chunks before the count is raised — one saturated chunk is just a burst.</summary>
internal const int BehindChunksBeforeRaise = 3;
/// <summary>The most the per-stream count is ever raised to, relative to the configured one.</summary>
internal const int SampleCountMaxGrowth = 16;
/// <summary>Absolute ceiling on a raised count (a stock cppagent buffer holds 131072 observations).</summary>
internal const int SampleCountAbsoluteCeiling = 65_536;
/// <summary>The count the current pump is streaming with. Diagnostics only.</summary>
private int _effectiveSampleCount;
/// <summary>The last measured lag (observations), or -1 when unknown. Diagnostics only.</summary>
private long _sampleLag = -1;
/// <summary>Chunks that carried the full count. Diagnostics only.</summary>
private long _saturatedChunks;
/// <summary>Times a pump raised its count. Diagnostics only.</summary>
private long _sampleCountRaises;
/// <summary>The ceiling a pump started with <paramref name="configured"/> may raise its count to.</summary>
/// <param name="configured">The configured <see cref="MTConnectDriverOptions.SampleCount"/>.</param>
/// <returns>The ceiling — never below <paramref name="configured"/>.</returns>
internal static int SampleCountCeiling(int configured) =>
Math.Max(configured, (int)Math.Min((long)configured * SampleCountMaxGrowth, SampleCountAbsoluteCeiling));
/// <inheritdoc />
public IReadOnlyDictionary<string, string> GetDiagnostics()
{
var bag = new Dictionary<string, string>(StringComparer.Ordinal);
var count = Volatile.Read(ref _effectiveSampleCount);
if (count > 0)
{
bag["sampleCount"] = count.ToString(CultureInfo.InvariantCulture);
}
var lag = Interlocked.Read(ref _sampleLag);
if (lag >= 0)
{
bag["sampleLag"] = lag.ToString(CultureInfo.InvariantCulture);
}
bag["sampleSaturatedChunks"] = Interlocked.Read(ref _saturatedChunks).ToString(CultureInfo.InvariantCulture);
bag["sampleCountRaises"] = Interlocked.Read(ref _sampleCountRaises).ToString(CultureInfo.InvariantCulture);
return bag;
}
/// <summary>
/// Measures one delivered chunk against the count its stream was opened with and records the result.
/// </summary>
/// <param name="chunk">The chunk just applied.</param>
/// <param name="sampleCount">The count the stream was opened with.</param>
/// <returns>Whether this chunk says the stream is behind the Agent.</returns>
private bool MeasureSampleLag(MTConnectStreamsResult chunk, int sampleCount)
{
long? lag = chunk.LastSequence is { } last ? Math.Max(0, last + 1 - chunk.NextSequence) : null;
Interlocked.Exchange(ref _sampleLag, lag ?? -1);
var saturated = chunk.Observations.Count >= sampleCount;
if (saturated)
{
Interlocked.Increment(ref _saturatedChunks);
}
return saturated && lag is not 0;
}
/// <summary>One pump's adaptive-count state. Only that pump's task touches it.</summary>
private sealed class SampleCountState(int configured)
{
/// <summary>The count the next stream opens with.</summary>
public int Count { get; set; } = configured;
/// <summary>The last count a stream actually delivered at.</summary>
public int LastDelivered { get; set; } = configured;
/// <summary>The most <see cref="Count"/> may be raised to; lowered to the last good count on a refusal.</summary>
public int Ceiling { get; set; } = SampleCountCeiling(configured);
/// <summary>Whether the at-the-ceiling Warning has been logged for this pump.</summary>
public bool CeilingWarned { get; set; }
}
/// <summary>
/// The count a behind stream should reopen with, or <see langword="null"/> when it is already at its ceiling
/// (logged once per pump).
/// </summary>
private int? NextSampleCount(SampleCountState state, MTConnectDriverOptions options, long cursor)
{
var sampleCount = state.Count;
var next = (int)Math.Min((long)sampleCount * 2, state.Ceiling);
if (next > sampleCount)
{
Interlocked.Increment(ref _sampleCountRaises);
_logger.LogInformation(
"MTConnect driver {DriverInstanceId} is falling behind {AgentUri}'s /sample stream (chunks full at count {Count}, lag {Lag} observation(s)); reopening at sequence {Cursor} with count {NextCount}.",
_driverInstanceId, AgentDisplay(options), sampleCount, DescribeLag(), cursor, next);
return next;
}
if (!state.CeilingWarned)
{
state.CeilingWarned = true;
_logger.LogWarning(
"MTConnect driver {DriverInstanceId} is still behind {AgentUri}'s /sample stream at its count ceiling {Count} (lag {Lag} observation(s)): the machine produces more than count / SampleIntervalMs observations a second, so values are backlog-stale and the stream will re-baseline (dropping intermediate transitions) whenever it falls out of the Agent's buffer. Lower SampleIntervalMs or raise SampleCount.",
_driverInstanceId, AgentDisplay(options), sampleCount, DescribeLag());
}
return null;
}
private string DescribeLag()
{
var lag = Interlocked.Read(ref _sampleLag);
return lag >= 0 ? lag.ToString(CultureInfo.InvariantCulture) : "unknown";
}
}
File diff suppressed because it is too large Load Diff
@@ -56,7 +56,7 @@ public sealed record MTConnectComponent(
/// <summary>
/// One MTConnect DataItem declaration from the Agent's probe response. This is pure metadata —
/// it carries no observed value; values arrive separately via
/// <see cref="IMTConnectAgentClient.CurrentAsync"/> / <see cref="IMTConnectAgentClient.SampleAsync"/>
/// <see cref="IMTConnectAgentClient.CurrentAsync"/> / <see cref="IMTConnectAgentClient.SampleAsync(long, CancellationToken)"/>
/// and are correlated back to a data item by <see cref="Id"/>.
/// </summary>
/// <param name="Id">
@@ -89,7 +89,7 @@ public sealed record MTConnectDataItem(
/// <summary>
/// Result of an Agent <c>/current</c> request, or of a single multipart chunk from a
/// <c>/sample</c> stream. Produced by <see cref="IMTConnectAgentClient.CurrentAsync"/> and
/// <see cref="IMTConnectAgentClient.SampleAsync"/>.
/// <see cref="IMTConnectAgentClient.SampleAsync(long, CancellationToken)"/>.
/// </summary>
/// <param name="InstanceId">
/// The Agent's current instance id (its MTConnectStreams header <c>instanceId</c>). Changes
@@ -98,7 +98,7 @@ public sealed record MTConnectDataItem(
/// </param>
/// <param name="NextSequence">
/// The header <c>nextSequence</c> — the sequence number to resume a subsequent
/// <see cref="IMTConnectAgentClient.SampleAsync"/> call <c>from</c>.
/// <see cref="IMTConnectAgentClient.SampleAsync(long, CancellationToken)"/> call <c>from</c>.
/// </param>
/// <param name="FirstSequence">
/// The header <c>firstSequence</c> — the oldest sequence number still held in the Agent's
@@ -110,11 +110,17 @@ public sealed record MTConnectDataItem(
/// The observations carried in this snapshot/chunk, in Agent-supplied order. Never <c>null</c>;
/// empty when the chunk carries no new observations (e.g. a heartbeat).
/// </param>
/// <param name="LastSequence">
/// The header <c>lastSequence</c> — the newest sequence number the Agent holds, or <c>null</c> when the
/// document omits it. With <see cref="NextSequence"/> it measures how far a <c>/sample</c> consumer is behind
/// the Agent: <c>LastSequence + 1 - NextSequence</c> observations it has not been sent yet (R06:T-5).
/// </param>
public sealed record MTConnectStreamsResult(
long InstanceId,
long NextSequence,
long FirstSequence,
IReadOnlyList<MTConnectObservation> Observations);
IReadOnlyList<MTConnectObservation> Observations,
long? LastSequence = null);
/// <summary>
/// One observed value for a single DataItem, as reported in a <c>/current</c> snapshot or a
@@ -162,11 +168,6 @@ public sealed record MTConnectStreamsResult(
/// The observation's own <c>sampleCount</c> attribute — the number of values a <c>TIME_SERIES</c>
/// vector claims to carry. The index checks the parsed vector against it. <c>null</c> when absent.
/// </param>
/// <param name="SampleRate">
/// The observation's own <c>sampleRate</c> attribute (samples per second) on a <c>TIME_SERIES</c>
/// observation. Carried for the parser contract; not yet surfaced on the served node (see
/// docs/drivers/MTConnect.md). <c>null</c> when absent.
/// </param>
/// <param name="Entries">
/// The parsed <c>&lt;Entry key="…"&gt;</c> children of a <c>DATA_SET</c> / <c>TABLE</c> observation, in
/// document order, or <c>null</c> when the observation had none. A <c>TABLE</c> entry carries
@@ -195,7 +196,6 @@ public sealed record MTConnectObservation(
DateTime TimestampUtc,
bool IsStructured = false,
int? SampleCount = null,
double? SampleRate = null,
MTConnectConditionDetail? Condition = null,
IReadOnlyList<MTConnectDataSetEntry>? Entries = null,
bool ResetTriggered = false,
@@ -151,7 +151,34 @@ internal sealed class MTConnectObservationIndex
TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
_tags = FreezeTags(tags);
}
/// <summary>Builds an index over an already-frozen tag map (see <see cref="FreezeTags"/>).</summary>
private MTConnectObservationIndex(FrozenDictionary<string, MTConnectTagDefinition> frozenTags, TimeProvider? timeProvider)
{
_timeProvider = timeProvider ?? TimeProvider.System;
_tags = frozenTags;
}
/// <summary>
/// R06:T-4 — an index over a tag map frozen ONCE (per binding) rather than per construction: the
/// <c>/current</c> read fallback builds a fresh index per batch, and re-freezing a 2–5k-entry tag table on
/// every read was a measurable share of that batch.
/// </summary>
/// <param name="frozenTags">A map produced by <see cref="FreezeTags"/>.</param>
/// <returns>A fresh, empty index coercing against <paramref name="frozenTags"/>.</returns>
internal static MTConnectObservationIndex OverFrozenTags(FrozenDictionary<string, MTConnectTagDefinition> frozenTags) =>
new(frozenTags, null);
/// <summary>
/// The authored tags keyed by <see cref="MTConnectTagDefinition.FullName"/>, last definition winning on a
/// duplicate (see the constructor), blank / null entries skipped.
/// </summary>
/// <param name="tags">The authored tags.</param>
/// <returns>The frozen map an index coerces against.</returns>
internal static FrozenDictionary<string, MTConnectTagDefinition> FreezeTags(IEnumerable<MTConnectTagDefinition>? tags)
{
var map = new Dictionary<string, MTConnectTagDefinition>(StringComparer.Ordinal);
foreach (var tag in tags ?? [])
{
@@ -160,7 +187,7 @@ internal sealed class MTConnectObservationIndex
map[tag.FullName] = tag;
}
_tags = map.ToFrozenDictionary(StringComparer.Ordinal);
return map.ToFrozenDictionary(StringComparer.Ordinal);
}
/// <summary>The number of distinct dataItemIds currently indexed.</summary>
@@ -118,7 +118,7 @@ internal static class MTConnectProbeParser
if (!MTConnectXml.IsNamed(root, "MTConnectDevices"))
{
throw new InvalidDataException(MTConnectXml.DescribeUnexpectedRoot(root, "MTConnectDevices", Subject));
throw MTConnectXml.UnexpectedRoot(root, "MTConnectDevices", Subject);
}
var devicesContainer = MTConnectXml.ChildrenNamed(root, "Devices").FirstOrDefault()
@@ -7,7 +7,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <remarks>
/// <para>
/// <b>Why these are exceptions rather than a normal end of enumeration.</b>
/// <see cref="IMTConnectAgentClient.SampleAsync"/> is contracted to yield indefinitely until
/// <see cref="IMTConnectAgentClient.SampleAsync(long, CancellationToken)"/> is contracted to yield indefinitely until
/// its token is cancelled, so a pump written to that contract has no reason to handle normal
/// completion — it would either drop the subscription silently or hot-loop reconnecting on
/// an enumerator that ends immediately. Returning normally would make "the data path
@@ -159,3 +159,4 @@ public sealed class MTConnectStreamNotSupportedException : MTConnectStreamExcept
{
}
}
@@ -295,8 +295,7 @@ internal static class MTConnectStreamsParser
// exactly this shape (Agents serve one under HTTP 200), and its <Error> elements — the
// Agent's own account of what it objected to — are the most useful thing in the whole
// response. Materialising the root is how the shared extraction can reach them.
throw new InvalidDataException(
MTConnectXml.DescribeUnexpectedRoot((XElement)XNode.ReadFrom(reader), "MTConnectStreams", Subject));
throw MTConnectXml.UnexpectedRoot((XElement)XNode.ReadFrom(reader), "MTConnectStreams", Subject);
}
var observations = new List<MTConnectObservation>();
@@ -330,7 +329,8 @@ internal static class MTConnectStreamsParser
sequences.InstanceId,
sequences.NextSequence,
sequences.FirstSequence,
observations);
observations,
sequences.LastSequence);
}
/// <summary>
@@ -342,7 +342,14 @@ internal static class MTConnectStreamsParser
new(
MTConnectXml.RequiredLongAttribute(reader, "instanceId", HeaderOwner, Subject),
MTConnectXml.RequiredLongAttribute(reader, "nextSequence", HeaderOwner, Subject),
MTConnectXml.RequiredLongAttribute(reader, "firstSequence", HeaderOwner, Subject));
MTConnectXml.RequiredLongAttribute(reader, "firstSequence", HeaderOwner, Subject),
// Optional on purpose: only the /sample lag measurement reads it, and a document without it must
// still parse (the pump then falls back to chunk saturation alone — R06:T-5).
long.TryParse(
MTConnectXml.OptionalAttribute(reader, "lastSequence"),
NumberStyles.Integer,
CultureInfo.InvariantCulture,
out var last) ? last : null);
/// <summary>
/// Consumes the element the reader is positioned on, collecting any observation it is and
@@ -383,7 +390,6 @@ internal static class MTConnectStreamsParser
var timestampUtc = default(DateTime);
string? conditionState = null;
int? sampleCount = null;
double? sampleRate = null;
var resetTriggered = false;
var hasCount = false;
List<MTConnectDataSetEntry>? entries = null;
@@ -421,11 +427,6 @@ internal static class MTConnectStreamsParser
NumberStyles.Integer,
CultureInfo.InvariantCulture,
out var count) && count >= 0 ? count : null;
sampleRate = double.TryParse(
MTConnectXml.OptionalAttribute(reader, "sampleRate"),
NumberStyles.Float,
CultureInfo.InvariantCulture,
out var rate) && double.IsFinite(rate) ? rate : null;
}
// The slot is reserved BEFORE the subtree is read, and filled after: an observation is
@@ -485,7 +486,6 @@ internal static class MTConnectStreamsParser
// away a perfectly well-determined state.
IsStructured: !isCondition && hasElements,
SampleCount: sampleCount,
SampleRate: sampleRate,
Condition: condition,
Entries: entries is { Count: > 0 } ? entries : null,
@@ -660,7 +660,7 @@ internal static class MTConnectStreamsParser
}
/// <summary>The three sequence numbers read off a <c>&lt;Header&gt;</c>.</summary>
private readonly record struct HeaderSequences(long InstanceId, long NextSequence, long FirstSequence);
private readonly record struct HeaderSequences(long InstanceId, long NextSequence, long FirstSequence, long? LastSequence);
/// <summary>
/// Accumulates one observation's text across however many nodes the parser hands it, without
@@ -0,0 +1,60 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <summary>
/// Review 2026-10-21 R06:T-8 — the display form of an Agent URI for every log line, exception message,
/// <c>DriverHealth.LastError</c> and <c>HostName</c> this driver produces. An <c>AgentUri</c> may carry
/// <c>user:pass@</c>; the Test-Connect probe already redacted it (<c>MTConnectDriverProbe.RedactedDisplay</c>)
/// while the driver itself logged and surfaced the raw string — and every request URI built from it.
/// </summary>
/// <remarks>
/// A URI with no user-info (and, for the configured string, no query or fragment) is returned <b>unchanged</b>,
/// so the common case — and every host key derived from it — is byte-identical to what it was. One that does
/// carry user-info loses it (and, for the configured string, its query / fragment, which a reverse proxy may use
/// for a token). A configured string that is not an absolute URI but contains an <c>@</c> is replaced wholesale:
/// there is no reliable way to find the credential in it.
/// </remarks>
internal static class MTConnectUriRedaction
{
/// <summary>Placeholder for a configured value that cannot be parsed and might carry a credential.</summary>
internal const string Unparseable = "(agentUri withheld: not an absolute URI)";
/// <summary>The display form of a configured <c>AgentUri</c>.</summary>
/// <param name="agentUri">The configured value.</param>
/// <returns>The value with any user-info (and query / fragment) removed.</returns>
public static string Redact(string? agentUri)
{
if (string.IsNullOrWhiteSpace(agentUri))
{
return agentUri ?? string.Empty;
}
if (!Uri.TryCreate(agentUri.Trim(), UriKind.Absolute, out var uri))
{
return agentUri.Contains('@', StringComparison.Ordinal) ? Unparseable : agentUri;
}
if (uri.UserInfo.Length == 0 && uri.Query.Length == 0 && uri.Fragment.Length == 0)
{
return agentUri;
}
var port = uri.IsDefaultPort ? string.Empty : $":{uri.Port}";
var path = uri.AbsolutePath == "/" ? string.Empty : uri.AbsolutePath;
return $"{uri.Scheme}://{uri.Host}{port}{path}";
}
/// <summary>The display form of a request URI built from the <c>AgentUri</c> (its query is the request's own).</summary>
/// <param name="uri">The request URI.</param>
/// <returns>The URI as text, without user-info.</returns>
public static string Redact(Uri uri)
{
ArgumentNullException.ThrowIfNull(uri);
if (!uri.IsAbsoluteUri || uri.UserInfo.Length == 0)
{
return uri.ToString();
}
return new UriBuilder(uri) { UserName = string.Empty, Password = string.Empty }.Uri.ToString();
}
}
@@ -190,7 +190,44 @@ internal static class MTConnectXml
/// <param name="root">The document's actual root element.</param>
/// <param name="expectedRootName">The root element name the caller required.</param>
/// <param name="subject">How to describe the document in the error message.</param>
/// <returns>The error message describing the unexpected root.</returns>
/// <returns>
/// The exception to throw. For an <c>MTConnectError</c> root (the Agent REFUSED the request) it is marked
/// with <see cref="AgentRefusalDataKey"/> — see <see cref="IsAgentRefusal"/>.
/// </returns>
public static InvalidDataException UnexpectedRoot(XElement root, string expectedRootName, string subject)
{
var ex = new InvalidDataException(DescribeUnexpectedRoot(root, expectedRootName, subject));
// Marked (InvalidDataException is sealed, so not a subtype) so the read path can tell a refusal — the
// Agent answered — from an answer it could not parse at all. Every other handler sees the same exception.
if (IsNamed(root, "MTConnectError"))
{
ex.Data[AgentRefusalDataKey] = true;
}
return ex;
}
/// <summary><see cref="Exception.Data"/> key marking an <c>MTConnectError</c> answer (see <see cref="IsAgentRefusal"/>).</summary>
internal const string AgentRefusalDataKey = "MTConnect.AgentRefusal";
/// <summary>
/// Whether <paramref name="ex"/> is the Agent answering with an <c>MTConnectError</c> document (e.g.
/// <c>NO_DEVICE</c>, <c>INVALID_REQUEST</c>) — a refusal, as opposed to an answer that could not be parsed.
/// </summary>
/// <param name="ex">The exception a parse raised.</param>
/// <returns><see langword="true"/> for a refusal.</returns>
public static bool IsAgentRefusal(Exception ex) =>
ex is InvalidDataException && ex.Data[AgentRefusalDataKey] is true;
/// <summary>
/// The text of <see cref="UnexpectedRoot"/>: the unexpected root, plus the Agent's own error text for an
/// <c>MTConnectError</c> document.
/// </summary>
/// <param name="root">The document's root element.</param>
/// <param name="expectedRootName">The root the caller expected.</param>
/// <param name="subject">How to describe the document.</param>
/// <returns>The message.</returns>
public static string DescribeUnexpectedRoot(XElement root, string expectedRootName, string subject)
{
var message =
@@ -17,6 +17,7 @@ internal sealed class OpcUaClientBrowseSession : IBrowseSession
private readonly NodeId _rootNodeId;
private readonly SemaphoreSlim _gate = new(1, 1);
private volatile bool _disposed;
private readonly IDisposable? _ownedCredential;
/// <summary>
/// Construct a browse session bound to an already-connected <paramref name="session"/>.
@@ -25,8 +26,10 @@ internal sealed class OpcUaClientBrowseSession : IBrowseSession
/// <param name="nsMap">Namespace snapshot taken at connect time; used to render outbound
/// NodeIds in the server-stable <c>nsu=…</c> form.</param>
/// <param name="rootNodeId">The node under which <see cref="RootAsync"/> browses one level.</param>
internal OpcUaClientBrowseSession(ISession session, NamespaceMap nsMap, NodeId rootNodeId)
/// <param name="ownedCredential">A credential (the user certificate) this session owns and disposes with itself.</param>
internal OpcUaClientBrowseSession(ISession session, NamespaceMap nsMap, NodeId rootNodeId, IDisposable? ownedCredential = null)
{
_ownedCredential = ownedCredential;
_session = session;
_nsMap = nsMap;
_rootNodeId = rootNodeId;
@@ -54,10 +57,64 @@ internal sealed class OpcUaClientBrowseSession : IBrowseSession
}
/// <inheritdoc />
public Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken cancellationToken)
// R06:O-5 — reads the node's NodeClass / DisplayName / DataType / ValueRank / UserAccessLevel in one request and
// maps them through the same UpstreamAttributeMapping the runtime driver's discovery registers with, so the
// side-panel shows what a browse-commit would author. An Object (folder) has no value attributes → empty.
// Throws ArgumentException when nodeId cannot be resolved against the live session's namespace table.
public async Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (!NamespaceMap.TryResolve(_session, nodeId, out var resolved))
throw new ArgumentException(
$"Cannot resolve NodeId '{nodeId}' against the live session.", nameof(nodeId));
uint[] attributeIds =
[
Attributes.NodeClass, Attributes.DisplayName, Attributes.DataType, Attributes.ValueRank,
Attributes.UserAccessLevel,
];
var toRead = new ReadValueIdCollection(attributeIds.Length);
foreach (var id in attributeIds) toRead.Add(new ReadValueId { NodeId = resolved, AttributeId = id });
DataValueCollection values;
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
var resp = await _session.ReadAsync(
requestHeader: null,
maxAge: 0,
timestampsToReturn: TimestampsToReturn.Neither,
nodesToRead: toRead,
ct: cancellationToken).ConfigureAwait(false);
values = resp?.Results ?? [];
}
finally
{
_gate.Release();
}
LastUsedUtc = DateTime.UtcNow;
return Task.FromResult<IReadOnlyList<AttributeInfo>>(Array.Empty<AttributeInfo>());
DataValue? At(int i) => i < values.Count && StatusCode.IsGood(values[i].StatusCode)
? values[i] : null;
var nodeClass = At(0)?.Value is int nc ? (NodeClass)nc : NodeClass.Unspecified;
if (nodeClass != NodeClass.Variable) return Array.Empty<AttributeInfo>();
var name = (At(1)?.Value as LocalizedText)?.Text ?? resolved.ToString() ?? nodeId;
var dataType = At(2)?.Value is NodeId dt
? UpstreamAttributeMapping.MapDataType(dt)
: ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.String;
var valueRank = At(3)?.Value is int vr ? vr : ValueRanks.Scalar;
var access = At(4)?.Value is byte ab ? ab : (byte)0;
return
[
new AttributeInfo(
Name: name,
DriverDataType: dataType.ToString(),
IsArray: UpstreamAttributeMapping.IsArray(valueRank),
SecurityClass: UpstreamAttributeMapping.MapAccessLevel(access).ToString()),
];
}
/// <summary>Issue a single-level Browse (plus continuation-point follow-ups) under the
@@ -188,5 +245,8 @@ internal sealed class OpcUaClientBrowseSession : IBrowseSession
try { _gate.Dispose(); }
catch { /* best-effort */ }
try { _ownedCredential?.Dispose(); }
catch { /* best-effort */ }
}
}
@@ -7,7 +7,8 @@ using Opc.Ua.Client;
using Opc.Ua.Configuration;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; // OpcUaClientDriverOptions + NamespaceMap
using ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; // OpcUaClientDriverOptions + NamespaceMap + OpcUaUserIdentityFactory
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser;
@@ -33,12 +34,19 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
};
private readonly ILogger<OpcUaClientDriverBrowser> _logger;
private readonly ISecretResolver? _secretResolver;
/// <summary>Creates a new browser. Logger defaults to NullLogger when not supplied.</summary>
/// <param name="logger">Optional logger; defaults to <see cref="NullLogger{T}"/>.</param>
public OpcUaClientDriverBrowser(ILogger<OpcUaClientDriverBrowser>? logger = null)
/// <param name="secretResolver">
/// The host's shared secret resolver (DI-injected where one is registered), used to resolve
/// <c>secret:</c>-prefixed <c>Password</c> / <c>UserCertificatePassword</c> exactly as the runtime driver does.
/// Null ⇒ a <c>secret:</c> ref fails closed (it is never sent as the literal).
/// </param>
public OpcUaClientDriverBrowser(ILogger<OpcUaClientDriverBrowser>? logger = null, ISecretResolver? secretResolver = null)
{
_logger = logger ?? NullLogger<OpcUaClientDriverBrowser>.Instance;
_secretResolver = secretResolver;
}
/// <inheritdoc />
@@ -58,58 +66,69 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
if (string.IsNullOrWhiteSpace(endpoint))
throw new InvalidOperationException("OpcUaClient browser requires EndpointUrl or EndpointUrls[0].");
if (opts.AuthType == OpcUaAuthType.Certificate)
throw new InvalidOperationException(
"Browser does not support OpcUaAuthType.Certificate in v1; use Anonymous or Username.");
if (opts.AutoAcceptCertificates)
_logger.LogWarning(
"AdminUI browse session opens against {Endpoint} with form's AutoAcceptCertificates=true — " +
"browse uses its own cert store and does NOT auto-accept; trust the cert via the runtime " +
"driver's PKI store instead.",
endpoint);
var appConfig = await BuildBrowseAppConfigurationAsync(cancellationToken).ConfigureAwait(false);
var identity = BuildBrowseUserIdentity(opts);
var perEndpointBudget = TimeSpan.FromSeconds(
Math.Clamp(opts.PerEndpointConnectTimeout.TotalSeconds, 5, 30));
using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
connectCts.CancelAfter(perEndpointBudget);
var endpointDesc = await SelectEndpointAsync(
appConfig, endpoint, opts.SecurityPolicy, opts.SecurityMode, connectCts.Token).ConfigureAwait(false);
var endpointCfg = EndpointConfiguration.Create(appConfig);
endpointCfg.OperationTimeout = (int)opts.Timeout.TotalMilliseconds;
var configuredEndpoint = new ConfiguredEndpoint(null, endpointDesc, endpointCfg);
var session = await new DefaultSessionFactory(telemetry: null!).CreateAsync(
appConfig,
configuredEndpoint,
updateBeforeConnect: false,
sessionName: "OtOpcUa AdminUI Browse",
(uint)opts.SessionTimeout.TotalMilliseconds,
identity,
preferredLocales: null,
connectCts.Token).ConfigureAwait(false);
// R06:O-5 — Certificate auth is supported: the identity is built (and any secret: credential resolved) by the
// same factory the runtime driver uses, BEFORE any network I/O, so a bad certificate path / password fails fast
// with its own message.
var (identity, ownedCertificate) = await BuildBrowseCredentialAsync(opts, _secretResolver, cancellationToken)
.ConfigureAwait(false);
try
{
var nsMap = NamespaceMap.FromSession(session);
var rootNodeId = string.IsNullOrEmpty(opts.BrowseRoot)
? ObjectIds.ObjectsFolder
: NodeId.Parse(session.MessageContext, opts.BrowseRoot);
if (opts.AutoAcceptCertificates)
_logger.LogWarning(
"AdminUI browse session opens against {Endpoint} with form's AutoAcceptCertificates=true — " +
"browse uses its own cert store and does NOT auto-accept; trust the cert via the runtime " +
"driver's PKI store instead.",
endpoint);
_logger.LogInformation(
"AdminUI OPC UA browse session opened against {Endpoint} (policy {Policy}, mode {Mode})",
endpoint, opts.SecurityPolicy, opts.SecurityMode);
var appConfig = await BuildBrowseAppConfigurationAsync(cancellationToken).ConfigureAwait(false);
return new OpcUaClientBrowseSession(session, nsMap, rootNodeId);
var perEndpointBudget = TimeSpan.FromSeconds(
Math.Clamp(opts.PerEndpointConnectTimeout.TotalSeconds, 5, 30));
using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
connectCts.CancelAfter(perEndpointBudget);
var endpointDesc = await SelectEndpointAsync(
appConfig, endpoint, opts.SecurityPolicy, opts.SecurityMode, connectCts.Token).ConfigureAwait(false);
var endpointCfg = EndpointConfiguration.Create(appConfig);
endpointCfg.OperationTimeout = (int)opts.Timeout.TotalMilliseconds;
var configuredEndpoint = new ConfiguredEndpoint(null, endpointDesc, endpointCfg);
var session = await new DefaultSessionFactory(telemetry: null!).CreateAsync(
appConfig,
configuredEndpoint,
updateBeforeConnect: false,
sessionName: "OtOpcUa AdminUI Browse",
(uint)opts.SessionTimeout.TotalMilliseconds,
identity,
preferredLocales: null,
connectCts.Token).ConfigureAwait(false);
try
{
var nsMap = NamespaceMap.FromSession(session);
var rootNodeId = string.IsNullOrEmpty(opts.BrowseRoot)
? ObjectIds.ObjectsFolder
: NodeId.Parse(session.MessageContext, opts.BrowseRoot);
_logger.LogInformation(
"AdminUI OPC UA browse session opened against {Endpoint} (policy {Policy}, mode {Mode})",
endpoint, opts.SecurityPolicy, opts.SecurityMode);
return new OpcUaClientBrowseSession(session, nsMap, rootNodeId, ownedCertificate);
}
catch
{
try { if (session is Session s) await s.CloseAsync().ConfigureAwait(false); } catch { /* best-effort */ }
try { session.Dispose(); } catch { /* best-effort */ }
throw;
}
}
catch
{
try { if (session is Session s) await s.CloseAsync().ConfigureAwait(false); } catch { /* best-effort */ }
try { session.Dispose(); } catch { /* best-effort */ }
// Review m9: the certificate (and its private-key handle) is owned by the browse session once handed
// over; any failure before that releases it here instead of leaving it to the GC.
ownedCertificate?.Dispose();
throw;
}
}
@@ -175,16 +194,39 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser
return config;
}
/// <summary>Build the OPC UA user identity from the form's auth fields.</summary>
/// <summary>
/// Build the OPC UA user identity from the form's auth fields — Anonymous, Username or Certificate — through
/// the factory the runtime driver uses, after resolving any <c>secret:</c>-prefixed credential (fail-closed).
/// </summary>
/// <param name="opts">Driver options carrying the form's auth fields.</param>
private static UserIdentity BuildBrowseUserIdentity(OpcUaClientDriverOptions opts) => opts.AuthType switch
/// <param name="secretResolver">The secret store, or null when none is available.</param>
/// <param name="ct">Cancellation token for the secret resolution.</param>
/// <returns>The identity the browse session activates with.</returns>
internal static async Task<UserIdentity> BuildBrowseUserIdentityAsync(
OpcUaClientDriverOptions opts, ISecretResolver? secretResolver, CancellationToken ct) =>
(await BuildBrowseCredentialAsync(opts, secretResolver, ct).ConfigureAwait(false)).Identity;
/// <summary>
/// As <see cref="BuildBrowseUserIdentityAsync"/>, also handing back the user certificate a Certificate-auth
/// identity holds, so the browse session that uses it can dispose it (review m9: the browse opens one per picker
/// session, and an undisposed <c>X509Certificate2</c> pins a private-key handle until GC).
/// </summary>
/// <param name="opts">Driver options carrying the form's auth fields.</param>
/// <param name="secretResolver">The secret store, or null when none is available.</param>
/// <param name="ct">Cancellation token for the secret resolution.</param>
/// <returns>The identity, and the certificate it owns (null for Anonymous / Username).</returns>
internal static async Task<(UserIdentity Identity, System.Security.Cryptography.X509Certificates.X509Certificate2? Certificate)>
BuildBrowseCredentialAsync(OpcUaClientDriverOptions opts, ISecretResolver? secretResolver, CancellationToken ct)
{
OpcUaAuthType.Anonymous => new UserIdentity(new AnonymousIdentityToken()),
OpcUaAuthType.Username => new UserIdentity(
opts.Username ?? string.Empty,
System.Text.Encoding.UTF8.GetBytes(opts.Password ?? string.Empty)),
_ => new UserIdentity(new AnonymousIdentityToken()),
};
var resolved = await OpcUaUserIdentityFactory.ResolveSecretRefsAsync(opts, secretResolver, ct).ConfigureAwait(false);
if (resolved.AuthType != OpcUaAuthType.Certificate)
{
return (OpcUaUserIdentityFactory.Build(resolved), null);
}
var certificate = OpcUaUserIdentityFactory.LoadCertificate(resolved);
return (new UserIdentity(certificate), certificate);
}
/// <summary>Select the endpoint matching the requested SecurityPolicy + SecurityMode pair.</summary>
/// <param name="appConfig">Application configuration used by the discovery client.</param>
@@ -0,0 +1,126 @@
using Opc.Ua;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
/// <summary>
/// The ONE place the OpcUaClient user identity is built from <see cref="OpcUaClientDriverOptions"/> — shared by
/// the runtime driver's session open and the AdminUI live browse, so a certificate-authenticated upstream is
/// browsable with exactly the credentials the deployed driver will present. Includes the <c>secret:</c>
/// resolution of the two credential fields (fail-closed).
/// </summary>
public static class OpcUaUserIdentityFactory
{
private const string SecretPrefix = "secret:";
/// <summary>
/// Build the user-identity token from options whose credential fields are already resolved plaintext
/// (see <see cref="ResolveSecretRefsAsync"/>).
/// </summary>
/// <param name="options">Driver options containing authentication configuration.</param>
/// <returns>The constructed <see cref="UserIdentity"/> for session activation.</returns>
public static UserIdentity Build(OpcUaClientDriverOptions options) =>
options.AuthType switch
{
OpcUaAuthType.Anonymous => new UserIdentity(new AnonymousIdentityToken()),
OpcUaAuthType.Username => new UserIdentity(
options.Username ?? string.Empty,
System.Text.Encoding.UTF8.GetBytes(options.Password ?? string.Empty)),
OpcUaAuthType.Certificate => FromCertificate(options),
_ => new UserIdentity(new AnonymousIdentityToken()),
};
/// <summary>
/// Build a <see cref="UserIdentity"/> carrying a client user-authentication certificate loaded from
/// <see cref="OpcUaClientDriverOptions.UserCertificatePath"/>. Fails fast if the path is missing, the file
/// doesn't exist, or the certificate lacks a private key (required to sign the user-token challenge).
/// </summary>
/// <param name="options">Driver options containing certificate configuration.</param>
/// <returns>The certificate-backed <see cref="UserIdentity"/>.</returns>
public static UserIdentity FromCertificate(OpcUaClientDriverOptions options) => new(LoadCertificate(options));
/// <summary>
/// Loads (and validates) the user-authentication certificate <see cref="FromCertificate"/> wraps. The caller owns
/// the returned certificate — dispose it when the session that presented it is gone.
/// </summary>
/// <param name="options">Driver options containing certificate configuration.</param>
/// <returns>The certificate, with its private key.</returns>
public static System.Security.Cryptography.X509Certificates.X509Certificate2 LoadCertificate(OpcUaClientDriverOptions options)
{
if (string.IsNullOrWhiteSpace(options.UserCertificatePath))
throw new InvalidOperationException(
"OpcUaAuthType.Certificate requires OpcUaClientDriverOptions.UserCertificatePath to be set.");
if (!File.Exists(options.UserCertificatePath))
throw new FileNotFoundException(
$"User certificate not found at '{options.UserCertificatePath}'.",
options.UserCertificatePath);
// X509CertificateLoader (new in .NET 9) is the only non-obsolete way to load a PFX
// since the legacy X509Certificate2 ctors are marked obsolete on net10.
var cert = System.Security.Cryptography.X509Certificates.X509CertificateLoader
.LoadPkcs12FromFile(options.UserCertificatePath, options.UserCertificatePassword);
if (!cert.HasPrivateKey)
{
cert.Dispose();
throw new InvalidOperationException(
$"User certificate at '{options.UserCertificatePath}' has no private key — " +
"the private key is required to sign the OPC UA user-token challenge at session activation.");
}
return cert;
}
/// <summary>
/// Return a copy of <paramref name="options"/> with any <c>secret:</c>-prefixed
/// <see cref="OpcUaClientDriverOptions.Password"/> / <see cref="OpcUaClientDriverOptions.UserCertificatePassword"/>
/// resolved through <paramref name="resolver"/>. Non-secret / null / empty fields pass through unchanged (the
/// reference-equal original is returned when nothing changed). Fail-closed: an absent secret, or a
/// <c>secret:</c> ref with no resolver available, throws rather than sending the literal as a credential.
/// </summary>
/// <param name="options">The raw options (credential fields may carry <c>secret:</c> refs).</param>
/// <param name="resolver">The shared secret resolver, or null when none is available.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>An options copy whose credential fields carry resolved plaintext.</returns>
public static async Task<OpcUaClientDriverOptions> ResolveSecretRefsAsync(
OpcUaClientDriverOptions options, ISecretResolver? resolver, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(options);
var password = await ResolveFieldAsync(options.Password, nameof(options.Password), resolver, ct)
.ConfigureAwait(false);
var certPassword = await ResolveFieldAsync(
options.UserCertificatePassword, nameof(options.UserCertificatePassword), resolver, ct)
.ConfigureAwait(false);
if (ReferenceEquals(password, options.Password)
&& ReferenceEquals(certPassword, options.UserCertificatePassword))
{
return options;
}
return options with { Password = password, UserCertificatePassword = certPassword };
}
private static async Task<string?> ResolveFieldAsync(
string? value, string fieldName, ISecretResolver? resolver, CancellationToken ct)
{
if (string.IsNullOrEmpty(value)
|| !value.StartsWith(SecretPrefix, StringComparison.OrdinalIgnoreCase))
{
return value;
}
var name = value[SecretPrefix.Length..];
if (resolver is null)
throw new InvalidOperationException(
$"OpcUaClientDriverOptions.{fieldName}='{value}' references secret '{name}', but no secret store is " +
"available here (fail-closed).");
var resolved = await resolver.GetAsync(new SecretName(name), ct).ConfigureAwait(false);
return !string.IsNullOrEmpty(resolved)
? resolved
: throw new InvalidOperationException(
$"OpcUaClientDriverOptions.{fieldName}='{value}' resolves secret '{name}', but it is " +
"absent from the store (fail-closed).");
}
}
@@ -0,0 +1,65 @@
using Opc.Ua;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
/// <summary>
/// The ONE mapping from an upstream variable's OPC UA attributes (DataType, ValueRank, AccessLevel) to the
/// driver-agnostic shape — shared by the runtime driver's discovery enrichment and the AdminUI live browse
/// side-panel, so the picker shows exactly what a browse-commit / discovery would register.
/// </summary>
public static class UpstreamAttributeMapping
{
/// <summary>
/// Map an upstream OPC UA built-in DataType NodeId (via <c>DataTypeIds.*</c>) to a
/// <see cref="DriverDataType"/>. Unknown / custom types fall through to
/// <see cref="DriverDataType.String"/> which is the safest passthrough for
/// Variant-wrapped structs + enums + extension objects.
/// </summary>
/// <param name="dataType">The OPC UA data type NodeId to map.</param>
/// <returns>The mapped <see cref="DriverDataType"/>.</returns>
public static DriverDataType MapDataType(NodeId dataType)
{
if (dataType == DataTypeIds.Boolean) return DriverDataType.Boolean;
// SByte (signed 8-bit) shares Int16 — DriverDataType has no narrower signed type.
// Byte (unsigned 8-bit) belongs in the unsigned family → UInt16, not Int16
// (mapping an unsigned 0-255 type onto Int16 misrepresents type metadata and
// confuses range/validation logic keyed off DriverDataType).
if (dataType == DataTypeIds.SByte || dataType == DataTypeIds.Int16) return DriverDataType.Int16;
if (dataType == DataTypeIds.Byte || dataType == DataTypeIds.UInt16) return DriverDataType.UInt16;
if (dataType == DataTypeIds.Int32) return DriverDataType.Int32;
if (dataType == DataTypeIds.UInt32) return DriverDataType.UInt32;
if (dataType == DataTypeIds.Int64) return DriverDataType.Int64;
if (dataType == DataTypeIds.UInt64) return DriverDataType.UInt64;
if (dataType == DataTypeIds.Float) return DriverDataType.Float32;
if (dataType == DataTypeIds.Double) return DriverDataType.Float64;
if (dataType == DataTypeIds.String) return DriverDataType.String;
if (dataType == DataTypeIds.DateTime || dataType == DataTypeIds.UtcTime)
return DriverDataType.DateTime;
return DriverDataType.String;
}
/// <summary>
/// Map an OPC UA AccessLevel/UserAccessLevel attribute value (<c>AccessLevels</c> bitmask) to a
/// <see cref="SecurityClassification"/>: CurrentWrite-capable variables surface as
/// <see cref="SecurityClassification.Operate"/>; read-only as <see cref="SecurityClassification.ViewOnly"/>.
/// </summary>
/// <param name="accessLevel">The OPC UA access level bitmask.</param>
/// <returns>The mapped security classification.</returns>
public static SecurityClassification MapAccessLevel(byte accessLevel)
{
const byte CurrentWrite = 2; // AccessLevels.CurrentWrite = 0x02
return (accessLevel & CurrentWrite) != 0
? SecurityClassification.Operate
: SecurityClassification.ViewOnly;
}
/// <summary>
/// OPC UA Part 3 ValueRank: -3 ScalarOrOneDimension, -2 Any, -1 Scalar, 0 OneOrMoreDimensions, 1 OneDimension,
/// &gt;1 N dimensions. Anything &gt;= 0 is an array; -3 / -2 are conservatively scalar until the upstream variable
/// carries a concrete dimensioned rank.
/// </summary>
/// <param name="valueRank">The ValueRank attribute.</param>
/// <returns>Whether the variable is exposed as an array.</returns>
public static bool IsArray(int valueRank) => valueRank >= 0;
}
@@ -11,8 +11,9 @@
</PropertyGroup>
<!--
The live-OPC-UA-session layer of the OpcUaClient driver: everything the driver AND the AdminUI's
browser both need that is typed in ISession / NodeId / NamespaceTable. Today that is exactly
NamespaceMap.
browser both need that is typed in ISession / NodeId / NamespaceTable / UserIdentity: NamespaceMap,
UpstreamAttributeMapping (DataType/AccessLevel/ValueRank -> driver shape) and OpcUaUserIdentityFactory
(credential secret: resolution + the SDK UserIdentity, incl. certificate auth).
WHY IT IS NOT IN .Contracts. It used to be, and .Contracts therefore carried
OPCFoundation.NetStandard.Opc.Ua.Client. When G-7 wired OpcUaClientTagDefinitionFactory.Inspect into
@@ -35,6 +36,10 @@
-->
<ItemGroup>
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" />
<!-- OpcUaUserIdentityFactory: the secret: resolution of the credential fields feeding the SDK UserIdentity is
shared by the driver and the AdminUI browse (review 2026-10-21 O-5), so the browse presents exactly the
credentials the deployed driver will. Abstractions only (one interface + a name type). -->
<PackageReference Include="ZB.MOM.WW.Secrets.Abstractions" />
</ItemGroup>
<ItemGroup>
<!-- RawTagEntry (the v3 authored-tag identity NamespaceMap indexes) lives in the Core.Abstractions
File diff suppressed because it is too large Load Diff
@@ -51,7 +51,8 @@ public sealed class OpcUaClientDriverProbe : IDriverProbe
// Bound the whole probe (both phases) by the caller timeout, independent of `ct` — a
// stalled OPC UA endpoint that accepts TCP but never answers GetEndpoints must not block
// the probe when the caller passes CancellationToken.None.
// the probe when the caller passes CancellationToken.None. The config's ProbeTimeoutSeconds caps it (O-4).
timeout = EffectiveTimeout(timeout, opts.ProbeTimeoutSeconds);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(timeout);
@@ -106,6 +107,23 @@ public sealed class OpcUaClientDriverProbe : IDriverProbe
}
}
/// <summary>
/// R06:O-4 — the deadline the probe actually runs under: the caller's budget, tightened by the config's
/// <see cref="OpcUaClientDriverOptions.ProbeTimeoutSeconds"/> when that is positive and shorter (it can only
/// shorten the budget — the caller's own deadline fires regardless). A non-positive caller budget is floored
/// to 1 s so the probe runs briefly rather than cancelling before it starts. Same rule as the Galaxy probe.
/// </summary>
/// <param name="callerTimeout">The budget the host handed the probe.</param>
/// <param name="configuredSeconds">The config's <c>ProbeTimeoutSeconds</c>; ignored when not positive.</param>
/// <returns>The effective probe deadline.</returns>
internal static TimeSpan EffectiveTimeout(TimeSpan callerTimeout, int configuredSeconds)
{
var caller = callerTimeout > TimeSpan.Zero ? callerTimeout : TimeSpan.FromSeconds(1);
if (configuredSeconds <= 0) return caller;
var configured = TimeSpan.FromSeconds(configuredSeconds);
return configured < caller ? configured : caller;
}
/// <summary>
/// Builds a minimal <see cref="ApplicationConfiguration"/> suitable for unauthenticated
/// discovery (GetEndpoints). No PKI stores, no session certificate — discovery traffic
@@ -21,8 +21,6 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
/// </remarks>
internal static class OpcUaClientSecretResolution
{
private const string SecretPrefix = "secret:";
/// <summary>
/// Return a copy of <paramref name="options"/> with any <c>secret:</c>-prefixed
/// <see cref="OpcUaClientDriverOptions.Password"/> /
@@ -37,54 +35,12 @@ internal static class OpcUaClientSecretResolution
/// <exception cref="InvalidOperationException">
/// A <c>secret:</c> ref names a secret that is absent/tombstoned in the store (fail-closed).
/// </exception>
internal static async Task<OpcUaClientDriverOptions> ResolveSecretRefsAsync(
internal static Task<OpcUaClientDriverOptions> ResolveSecretRefsAsync(
OpcUaClientDriverOptions options, ISecretResolver resolver, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(resolver);
var password = await ResolveFieldAsync(options.Password, nameof(options.Password), resolver, ct)
.ConfigureAwait(false);
var certPassword = await ResolveFieldAsync(
options.UserCertificatePassword, nameof(options.UserCertificatePassword), resolver, ct)
.ConfigureAwait(false);
// Only re-materialize when something actually changed — a `with` on the reference-equal
// strings is harmless, but skipping it keeps the common (no-secret) case allocation-free.
if (ReferenceEquals(password, options.Password)
&& ReferenceEquals(certPassword, options.UserCertificatePassword))
{
return options;
}
return options with { Password = password, UserCertificatePassword = certPassword };
}
/// <summary>
/// Resolve a single credential field. Null/empty or non-<c>secret:</c> values pass through
/// unchanged (the reference-equal original is returned). A <c>secret:NAME</c> value is
/// resolved through <paramref name="resolver"/> and is fail-closed when the secret is absent.
/// </summary>
/// <param name="value">The raw field value (may be a <c>secret:</c> ref).</param>
/// <param name="fieldName">The field name, used in the fail-closed exception message.</param>
/// <param name="resolver">The shared secret resolver.</param>
/// <param name="ct">Cancellation token for the async resolution.</param>
/// <returns>The resolved plaintext, or the original value when it is not a <c>secret:</c> ref.</returns>
private static async Task<string?> ResolveFieldAsync(
string? value, string fieldName, ISecretResolver resolver, CancellationToken ct)
{
if (string.IsNullOrEmpty(value)
|| !value.StartsWith(SecretPrefix, StringComparison.OrdinalIgnoreCase))
{
return value;
}
var name = value[SecretPrefix.Length..];
var resolved = await resolver.GetAsync(new SecretName(name), ct).ConfigureAwait(false);
return !string.IsNullOrEmpty(resolved)
? resolved
: throw new InvalidOperationException(
$"OpcUaClientDriverOptions.{fieldName}='{value}' resolves secret '{name}', but it is " +
"absent from the store (fail-closed).");
// One authority, shared with the AdminUI live browse (Driver.OpcUaClient.Session).
return OpcUaUserIdentityFactory.ResolveSecretRefsAsync(options, resolver, ct);
}
}
@@ -219,7 +219,7 @@
<div class="col-md-3">
<label class="form-label">Admin UI probe timeout (seconds)</label>
<InputNumber @bind-Value="_form.OpcUa.ProbeTimeoutSeconds" @bind-Value:after="EmitAsync" class="form-control form-control-sm" />
<div class="form-text">Max 60. Used by Test Connect. Default 15.</div>
<div class="form-text">Caps Test Connect: it can only shorten the 10 s Test Connect budget, never extend it.</div>
</div>
</div>
</div>