Merge lane G1: OpcUaClient + MTConnect driver remediation (#743)
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -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);
|
||||
|
||||
+1
-10
@@ -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&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><Entry key="…"></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><Header></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 =
|
||||
|
||||
+63
-3
@@ -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 */ }
|
||||
}
|
||||
}
|
||||
|
||||
+96
-54
@@ -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,
|
||||
/// >1 N dimensions. Anything >= 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;
|
||||
}
|
||||
+7
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user