diff --git a/Directory.Packages.props b/Directory.Packages.props
index baf53c23..c9ad0acf 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -84,6 +84,11 @@
ships net6.0/net8.0 only.
-->
+
+
+
+
+
+
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/IMTConnectAgentClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/IMTConnectAgentClient.cs
new file mode 100644
index 00000000..1d41561e
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/IMTConnectAgentClient.cs
@@ -0,0 +1,135 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
+
+///
+/// The seam between the MTConnect driver and an MTConnect Agent's HTTP surface
+/// (/probe, /current, /sample). This interface is transport-neutral by
+/// design — every method returns/yields the plain DTOs in MTConnectDtos.cs, never a
+/// TrakHound type, an XElement, or any other wire/library artifact. That is what lets
+/// every driver behaviour above this seam (Tasks 6–13) be unit-tested against a fake serving
+/// canned probe/current/sample XML, with no sockets involved.
+///
+///
+///
+/// The production implementation, , carries no backend NuGet
+/// at all: it is an over the Agent's three request paths, feeding
+/// hand-rolled System.Xml.Linq parsers (MTConnectProbeParser /
+/// MTConnectStreamsParser) plus a multipart/x-mixed-replace frame reader for the
+/// /sample long poll. (Task 0 planned to build on the TrakHound MTConnect.NET libraries;
+/// Tasks 6 and 7 proved they can neither parse a document nor frame the stream socket-free, and
+/// dropped the references — see the Task 0 CORRECTION block in the plan.) A fake for tests only
+/// needs to implement these three instance members; is a static
+/// protocol rule that lives here, rather than on the implementation, so a consumer written
+/// against this seam never has to reference the concrete client.
+///
+///
+/// Why the seam is rather than
+/// . The driver obtains its client through a
+/// Func<MTConnectDriverOptions, IMTConnectAgentClient> and must release it on
+/// ShutdownAsync and on every endpoint-changing ReinitializeAsync. Declaring
+/// disposal here — instead of the driver doing as IDisposable + a null-conditional
+/// call — is deliberate: that idiom silently no-ops against any future non-disposable
+/// implementation, and the thing it would leak is an and its socket
+/// handler, once per re-deploy, for the life of the process.
+///
+///
+/// Synchronous disposal is the honest shape because every resource behind this seam is
+/// synchronously disposable — two s and a
+/// . An here would be a
+/// synchronous method wearing a ValueTask, and it would oblige every canned-XML fake
+/// to grow one too. The async unwind that a streaming client does need is already
+/// covered elsewhere and is not this method's job: the enumerator
+/// is itself and is owned by the pump that enumerates it,
+/// while on the client converts any read still in flight
+/// into an ordinary .
+///
+///
+public interface IMTConnectAgentClient : IDisposable
+{
+ ///
+ /// Issues an Agent /probe request and returns the parsed device hierarchy. Called
+ /// once at driver initialization; the result is cached and later walked to build the OPC UA
+ /// browse tree, where each becomes a tag's FullName.
+ ///
+ /// Cancellation token.
+ Task ProbeAsync(CancellationToken ct);
+
+ ///
+ /// Issues an Agent /current 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 .
+ ///
+ /// Cancellation token.
+ Task CurrentAsync(CancellationToken ct);
+
+ ///
+ /// Opens an Agent /sample long-poll stream starting at sequence
+ /// and yields one per multipart chunk the Agent sends.
+ ///
+ ///
+ ///
+ /// The only way this enumeration ends without throwing is being
+ /// cancelled. Every other way a stream can stop — the connection dropping, the Agent
+ /// writing its closing boundary, or the endpoint turning out not to stream at all —
+ /// throws an naming which. A consumer therefore
+ /// never has to treat "the enumeration finished" as an ambiguous signal, and cannot
+ /// silently lose its subscription to a data path that quietly stopped (#485). See
+ /// (transient — reconnect) and
+ /// (configuration — reconnecting will
+ /// reproduce it forever).
+ ///
+ ///
+ /// Advancing the cursor. After each chunk, the caller's next expected sequence is
+ /// that chunk's — not the
+ /// this call was opened with. Feed that running value to
+ /// and, on reconnect, to a fresh SampleAsync.
+ ///
+ ///
+ /// The sequence number to resume streaming from.
+ /// Cancellation token; cancelling is the contracted way to end the stream.
+ IAsyncEnumerable SampleAsync(long from, CancellationToken ct);
+
+ ///
+ /// Has the Agent's ring buffer overflowed past the sequence the caller expected next? True
+ /// when the chunk's oldest retained sequence is strictly newer than
+ /// , meaning observations between the two were evicted before
+ /// the caller could read them and an incremental update would silently skip values.
+ ///
+ ///
+ ///
+ /// is a running cursor, not the original
+ /// from. It equals the from passed to only
+ /// for the first chunk; from the second chunk on it is the previous
+ /// chunk's . Comparing every chunk
+ /// against the original from instead reports a gap on every chunk once the
+ /// Agent's buffer has rolled past it — an endless /current re-baseline storm
+ /// against a perfectly healthy stream.
+ ///
+ ///
+ /// Equality is not a gap: firstSequence == expectedFrom is the ordinary
+ /// contiguous case where the Agent simply had nothing older to send.
+ ///
+ ///
+ /// This check alone does NOT cover every ring-buffer overflow. It can only judge
+ /// a chunk the Agent was willing to send — and cppagent answers a from that has
+ /// already fallen out of its buffer with an MTConnectError / OUT_OF_RANGE
+ /// document served under HTTP 200, which this client surfaces as an
+ /// out of , never as a chunk.
+ /// A pump that re-baselines only on IsSequenceGap will therefore fault instead of
+ /// recovering exactly when it has fallen furthest behind. Treat an OUT_OF_RANGE
+ /// parse failure as a re-baseline trigger (re-prime from ,
+ /// resume from that snapshot's NextSequence) just like a detected gap.
+ ///
+ ///
+ /// The sequence the caller expected this chunk to start at.
+ /// The chunk the Agent answered with.
+ ///
+ /// true when observations were lost and the caller must re-baseline via
+ /// rather than trusting an incremental update.
+ ///
+ static bool IsSequenceGap(long expectedFrom, MTConnectStreamsResult chunk)
+ {
+ ArgumentNullException.ThrowIfNull(chunk);
+
+ return chunk.FirstSequence > expectedFrom;
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectAgentClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectAgentClient.cs
new file mode 100644
index 00000000..8ce654b1
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectAgentClient.cs
@@ -0,0 +1,726 @@
+using System.Globalization;
+using System.Runtime.CompilerServices;
+using System.Text;
+using Microsoft.Extensions.Logging;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
+
+///
+/// The production — a thin wrapper
+/// over an MTConnect Agent's REST surface that hands every response body to a pure parser and
+/// returns only the neutral DTOs in MTConnectDtos.cs.
+///
+///
+///
+/// The constructor opens no connection. It only composes the request URIs and builds
+/// its s (which themselves dial nothing until a request is issued),
+/// so a throwaway instance is safe to construct — the universal discovery browser's
+/// CanBrowse pattern depends on that. likewise dials
+/// nothing until it is enumerated.
+///
+///
+/// Two s, and the split is deliberate.
+/// is a per-instance, whole-response deadline, so a single
+/// client cannot serve both legs: the unary /probe and /current calls must be
+/// bounded by , while /sample is
+/// a long poll that is supposed to outlive that bound indefinitely. Raising the one
+/// timeout to suit the stream would un-bound the unary deadline — precisely what arch-review
+/// R2-01 says must never happen — so the streaming leg gets its own client with
+/// and is bounded differently instead (below). In
+/// production each client owns its own handler and connection pool; in tests both are built
+/// over one injected handler, which the caller owns.
+///
+///
+/// Every call is deadline-bounded, but not all by the same mechanism. The unary legs
+/// run under a linked to the caller's token and
+/// cancelled after RequestTimeoutMs. The streaming leg bounds its header phase
+/// the same way (a peer that accepts the connection and never answers must not wedge the
+/// pump), then hands the body to a heartbeat watchdog: MTConnect Agents emit a
+/// keep-alive chunk every precisely so a
+/// client can tell "quiet but alive" from "dead", so every body read is bounded by a
+/// two-missed-heartbeat window and a silent Agent surfaces as a
+/// . This is the exact shape of this repo's S7 frozen-peer
+/// production defect (arch-review R2-01) — an async read that ignores the socket deadline —
+/// and the watchdog is what closes it here.
+///
+///
+/// Every operator-authorable duration/count is rejected at construction if non-positive.
+/// A 0 must never come to mean "wait forever" (arch-review 01/S-6), and the three
+/// /sample query knobs additionally reach the wire: an Agent answers
+/// heartbeat=0 with an MTConnectError under HTTP 200, which would otherwise
+/// surface as a parse failure that never names the offending config key.
+///
+///
+/// Disposal is safe against an in-flight enumeration. Task 9 re-initializes the
+/// driver by disposing and rebuilding this client, which can happen while a pump is still
+/// enumerating . Rather than letting the in-flight read fault with
+/// an unclassified or 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
+/// .
+///
+///
+/// This class does not swallow failures into an empty model: an unreachable Agent, a non-2xx
+/// status, an unparseable body, and the end of a /sample stream all throw. See
+/// for why the last of those is an exception rather
+/// than a normal end of enumeration. InitializeAsync (Task 9) is what catches these
+/// and moves the driver to Faulted.
+///
+///
+public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
+{
+ private readonly HttpClient _http;
+ private readonly HttpClient _streamHttp;
+ private readonly CancellationTokenSource _disposeCts = new();
+ private readonly ILogger? _logger;
+ private readonly Uri _probeUri;
+ private readonly Uri _currentUri;
+ private readonly string _sampleUriBase;
+ private readonly string _sampleQuerySuffix;
+ private readonly TimeSpan _requestTimeout;
+ private readonly TimeSpan _streamIdleTimeout;
+ private bool _disposed;
+
+ /// Creates a client for the Agent described by .
+ /// The driver options carrying the Agent URI, device scope, and per-call deadline.
+ ///
+ /// Optional logger. Used only for conditions an operator needs to see but which are not
+ /// failures — today, the degraded /sample framing mode.
+ ///
+ /// is missing or is not an absolute HTTP(S) URI.
+ ///
+ /// Any of ,
+ /// ,
+ /// or
+ /// is not positive.
+ ///
+ public MTConnectAgentClient(MTConnectDriverOptions options, ILogger? logger = null)
+ : this(options, handler: null, logger)
+ {
+ }
+
+ ///
+ /// Test seam: as , but
+ /// sends through the supplied handler so the request/response round trip can be exercised
+ /// with no socket. The caller retains ownership of .
+ ///
+ internal MTConnectAgentClient(MTConnectDriverOptions options, HttpMessageHandler? handler, ILogger? logger = null)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ RequirePositive(
+ options.RequestTimeoutMs,
+ nameof(MTConnectDriverOptions.RequestTimeoutMs),
+ "a non-positive per-call deadline would let a frozen Agent wedge the driver indefinitely");
+ RequirePositive(
+ options.HeartbeatMs,
+ nameof(MTConnectDriverOptions.HeartbeatMs),
+ "it is sent to the Agent as 'heartbeat', which an Agent rejects with an MTConnectError under HTTP 200, and it is what bounds the /sample watchdog — a quiet connection would otherwise be indistinguishable from a dead one");
+ RequirePositive(
+ options.SampleIntervalMs,
+ nameof(MTConnectDriverOptions.SampleIntervalMs),
+ "it is sent to the Agent as 'interval'; zero would spin the streaming connection into a busy-loop");
+ RequirePositive(
+ options.SampleCount,
+ nameof(MTConnectDriverOptions.SampleCount),
+ "it is sent to the Agent as 'count'; a non-positive count asks the Agent for no observations");
+
+ _logger = logger;
+ _requestTimeout = TimeSpan.FromMilliseconds(options.RequestTimeoutMs);
+
+ // Two missed heartbeats plus one request timeout of network slack. Deriving the window from
+ // HeartbeatMs is what makes it a liveness check rather than a throughput assumption: a busy
+ // Agent and an idle one both emit at least one chunk per heartbeat.
+ _streamIdleTimeout = TimeSpan.FromMilliseconds((2L * options.HeartbeatMs) + options.RequestTimeoutMs);
+
+ _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}");
+
+ _http = handler is null ? new HttpClient() : new HttpClient(handler, disposeHandler: false);
+ _http.Timeout = _requestTimeout;
+
+ _streamHttp = handler is null ? new HttpClient() : new HttpClient(handler, disposeHandler: false);
+
+ // See the class remarks: a long poll must outlive the unary deadline by design, and the
+ // heartbeat watchdog — not this property — is what keeps it bounded.
+ _streamHttp.Timeout = Timeout.InfiniteTimeSpan;
+ }
+
+ ///
+ public async Task ProbeAsync(CancellationToken ct)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ // ResponseContentRead (the default) buffers the whole body *under* this awaited, token-bound
+ // call, so the parse below reads from memory — no network read can outlive the deadline.
+ using var response = await SendAsync(_probeUri, ct).ConfigureAwait(false);
+
+ await using var body = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
+
+ return MTConnectProbeParser.Parse(body);
+ }
+
+ ///
+ public async Task CurrentAsync(CancellationToken ct)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ using var response = await SendAsync(_currentUri, ct).ConfigureAwait(false);
+
+ await using var body = await response.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
+
+ return MTConnectStreamsParser.Parse(body);
+ }
+
+ ///
+ public async IAsyncEnumerable SampleAsync(
+ long from,
+ [EnumeratorCancellation] CancellationToken ct)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+
+ var uri = BuildSampleUri(from);
+
+ // 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.
+ using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(ct, _disposeCts.Token);
+ var live = lifetime.Token;
+
+ using var request = new HttpRequestMessage(HttpMethod.Get, uri);
+
+ // ResponseHeadersRead, not the default ResponseContentRead: buffering a
+ // multipart/x-mixed-replace body that is designed never to end would hang here forever.
+ using var response = await SendStreamRequestAsync(request, uri, lifetime).ConfigureAwait(false);
+
+ await using var body = await response.Content.ReadAsStreamAsync(live).ConfigureAwait(false);
+
+ var boundary = ResolveMultipartBoundary(response, uri);
+ if (boundary is null)
+ {
+ // Not a multipart stream: the Agent answered with a single document, which means it
+ // ignored the streaming request entirely. Parse it first — an MTConnectError arrives in
+ // exactly this shape (text/xml under HTTP 200) and the Agent's own error text is far
+ // more useful than ours — then report the misconfiguration. The parsed snapshot is
+ // deliberately NOT yielded: one document followed by a healthy-looking finished stream
+ // is how a dead data path disguises itself as a working one (#485).
+ var single = await ReadWholeBodyAsync(body, uri, lifetime).ConfigureAwait(false);
+ _ = 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.");
+ }
+
+ var reader = new MultipartStreamReader(body, boundary, _streamIdleTimeout, uri, _logger);
+ var delivered = 0L;
+ while (true)
+ {
+ var part = await reader.ReadNextPartAsync(live).ConfigureAwait(false);
+ if (part is null)
+ {
+ // The caller cancelling is the ONE contracted way out; anything else is reported.
+ live.ThrowIfCancellationRequested();
+
+ throw new MTConnectStreamEndedException(reader.EndReason, uri.AbsoluteUri, delivered);
+ }
+
+ // A frame carrying nothing but whitespace is transport filler, not a document. The
+ // Agent's real keep-alive is a well-formed observation-free MTConnectStreams document,
+ // and that one IS yielded — it advances the sequence, so swallowing it would strand the
+ // caller's `from` behind the Agent's buffer.
+ if (string.IsNullOrWhiteSpace(part))
+ {
+ continue;
+ }
+
+ delivered++;
+
+ yield return MTConnectStreamsParser.Parse(part);
+ }
+ }
+
+ ///
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+
+ // Cancel BEFORE disposing the clients so an in-flight read observes cancellation rather
+ // than a disposed handler.
+ _disposeCts.Cancel();
+ _http.Dispose();
+ _streamHttp.Dispose();
+ _disposeCts.Dispose();
+ }
+
+ private static void RequirePositive(int value, string optionName, string because)
+ {
+ if (value <= 0)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(MTConnectDriverOptions),
+ value,
+ $"{optionName} must be positive; {because}.");
+ }
+ }
+
+ private Uri BuildSampleUri(long from) =>
+ new(string.Create(CultureInfo.InvariantCulture, $"{_sampleUriBase}?from={from}{_sampleQuerySuffix}"),
+ UriKind.Absolute);
+
+ private async Task SendAsync(Uri uri, CancellationToken ct)
+ {
+ using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(ct, _disposeCts.Token);
+ using var deadline = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);
+ deadline.CancelAfter(_requestTimeout);
+
+ HttpResponseMessage response;
+ try
+ {
+ response = await _http.GetAsync(uri, deadline.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (!lifetime.IsCancellationRequested)
+ {
+ // Neither the caller nor disposal cancelled, so this is our own deadline (or
+ // HttpClient.Timeout) firing. Reported as a TimeoutException because a bare "A task was
+ // canceled" tells an operator nothing about which Agent stopped answering.
+ throw TimedOut(uri);
+ }
+
+ response.EnsureSuccessStatusCode();
+
+ return response;
+ }
+
+ ///
+ /// Issues the /sample request and returns as soon as the response headers land. Only
+ /// this phase is bounded by the unary deadline; the body is bounded by the watchdog.
+ ///
+ private async Task SendStreamRequestAsync(
+ HttpRequestMessage request, Uri uri, CancellationTokenSource lifetime)
+ {
+ using var deadline = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);
+ deadline.CancelAfter(_requestTimeout);
+
+ HttpResponseMessage response;
+ try
+ {
+ response = await _streamHttp
+ .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, deadline.Token)
+ .ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (!lifetime.IsCancellationRequested)
+ {
+ throw TimedOut(uri);
+ }
+
+ response.EnsureSuccessStatusCode();
+
+ return response;
+ }
+
+ /// Reads a non-multipart streaming body whole, under the unary deadline.
+ private async Task ReadWholeBodyAsync(Stream body, Uri uri, CancellationTokenSource lifetime)
+ {
+ using var deadline = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);
+ deadline.CancelAfter(_requestTimeout);
+
+ using var reader = new StreamReader(body, Encoding.UTF8);
+ try
+ {
+ return await reader.ReadToEndAsync(deadline.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (!lifetime.IsCancellationRequested)
+ {
+ throw TimedOut(uri);
+ }
+ }
+
+ private TimeoutException TimedOut(Uri uri) =>
+ new($"MTConnect Agent request to '{uri}' did not complete within {_requestTimeout.TotalMilliseconds:F0} ms.");
+
+ ///
+ /// The boundary parameter of a multipart/* response, or null when the
+ /// Agent answered with a single (non-multipart) document.
+ ///
+ ///
+ /// The response claims to be multipart/* but carries no boundary parameter, so
+ /// there is nothing to frame it by. Failed fast and named, because the alternative — falling
+ /// through to whole-body reading of a body that never ends — surfaces much later as a bare
+ /// watchdog that points an operator at the network instead of
+ /// at the malformed header.
+ ///
+ private static string? ResolveMultipartBoundary(HttpResponseMessage response, Uri uri)
+ {
+ var contentType = response.Content.Headers.ContentType;
+ if (contentType?.MediaType is null ||
+ !contentType.MediaType.StartsWith("multipart/", StringComparison.OrdinalIgnoreCase))
+ {
+ return null;
+ }
+
+ var boundary = contentType.Parameters
+ .FirstOrDefault(p => string.Equals(p.Name, "boundary", StringComparison.OrdinalIgnoreCase))?.Value?
+ .Trim('"');
+
+ 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.");
+ }
+
+ return boundary;
+ }
+
+ private static Uri BuildRequestUri(MTConnectDriverOptions options, string request)
+ {
+ var agentUri = options.AgentUri?.Trim();
+ if (string.IsNullOrEmpty(agentUri))
+ {
+ throw new ArgumentException(
+ $"{nameof(MTConnectDriverOptions.AgentUri)} is required (e.g. 'http://agent:5000').", nameof(options));
+ }
+
+ if (!Uri.TryCreate(agentUri, UriKind.Absolute, out var parsed) ||
+ (parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps))
+ {
+ throw new ArgumentException(
+ $"{nameof(MTConnectDriverOptions.AgentUri)} '{agentUri}' is not an absolute http(s) URI.", nameof(options));
+ }
+
+ var root = parsed.GetLeftPart(UriPartial.Path).TrimEnd('/');
+ var deviceName = options.DeviceName?.Trim();
+ var deviceSegment = string.IsNullOrEmpty(deviceName) ? string.Empty : $"/{Uri.EscapeDataString(deviceName)}";
+
+ return new Uri($"{root}{deviceSegment}/{request}", UriKind.Absolute);
+ }
+
+ ///
+ /// Frames a multipart/x-mixed-replace body into individual part payloads, reading
+ /// incrementally and never buffering more than the part currently in flight.
+ ///
+ ///
+ ///
+ /// Why hand-rolled. ASP.NET Core's MultipartReader is not in this
+ /// dependency set, and TrakHound's MTConnectHttpClientStream — the one candidate
+ /// the plan left open — takes a URL and dials its own socket, so it can neither be fed an
+ /// existing response stream nor be exercised without a listener. See the Task 0
+ /// correction in the plan.
+ ///
+ ///
+ /// Two framing paths, and the first one matters for latency. When the part carries
+ /// a Content-length header — every Agent in the wild writes one — the body is read
+ /// by length and the chunk is emitted the instant it is complete. Without it there is no
+ /// way to know a part has ended except by seeing the next boundary, so that path
+ /// necessarily emits each chunk one chunk late: with the default 10 s heartbeat a value
+ /// can be up to a heartbeat stale on a driver built for low-latency subscribe. It is a
+ /// correctness fallback, not the expected path, and it logs a one-shot warning when it
+ /// engages so the degradation is visible rather than merely slow. (It cannot strand the
+ /// final part indefinitely — a clean end of stream returns the buffered tail — though a
+ /// watchdog timeout on a stalled Agent does discard it.)
+ ///
+ ///
+ /// Every read is watchdog-bounded. A silent peer faults the stream rather than
+ /// parking a task forever (arch-review R2-01), and the buffer is capped so a hostile or
+ /// malfunctioning Agent cannot grow it without limit.
+ ///
+ ///
+ private sealed class MultipartStreamReader(
+ Stream stream, string boundary, TimeSpan idleTimeout, Uri uri, ILogger? logger)
+ {
+ private const int MaxBufferBytes = 32 * 1024 * 1024;
+
+ private static readonly byte[] CrLfCrLf = "\r\n\r\n"u8.ToArray();
+ private static readonly byte[] LfLf = "\n\n"u8.ToArray();
+
+ private readonly byte[] _delimiter = Encoding.ASCII.GetBytes("--" + boundary);
+ private byte[] _buffer = new byte[16 * 1024];
+ private int _length;
+ private bool _finished;
+ private bool _warnedNoContentLength;
+
+ /// How the stream ended. Only meaningful once a read has returned null.
+ public MTConnectStreamEndReason EndReason { get; private set; } = MTConnectStreamEndReason.ConnectionClosed;
+
+ ///
+ /// Reads the next part's payload, or null once the closing delimiter is seen or
+ /// the Agent closes the connection — see for which.
+ ///
+ public async Task ReadNextPartAsync(CancellationToken ct)
+ {
+ if (_finished)
+ {
+ return null;
+ }
+
+ if (!await SkipThroughDelimiterAsync(ct).ConfigureAwait(false) ||
+ !await EnsureAsync(2, ct).ConfigureAwait(false))
+ {
+ return Finish(MTConnectStreamEndReason.ConnectionClosed);
+ }
+
+ // "--boundary--" closes the stream.
+ if (_buffer[0] == (byte)'-' && _buffer[1] == (byte)'-')
+ {
+ return Finish(MTConnectStreamEndReason.ClosingBoundary);
+ }
+
+ var headerEnd = await FindHeaderEndAsync(ct).ConfigureAwait(false);
+ if (headerEnd < 0)
+ {
+ return Finish(MTConnectStreamEndReason.ConnectionClosed);
+ }
+
+ var headers = Encoding.ASCII.GetString(_buffer, 0, headerEnd);
+ Consume(headerEnd);
+
+ byte[]? body;
+ if (ParseContentLength(headers) is { } declared)
+ {
+ body = await ReadByLengthAsync(declared, ct).ConfigureAwait(false);
+ }
+ else
+ {
+ WarnOnceAboutMissingContentLength();
+ body = await ReadToNextDelimiterAsync(ct).ConfigureAwait(false);
+ }
+
+ return body is null ? null : Encoding.UTF8.GetString(body);
+ }
+
+ private void WarnOnceAboutMissingContentLength()
+ {
+ if (_warnedNoContentLength)
+ {
+ return;
+ }
+
+ _warnedNoContentLength = true;
+
+ 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);
+ }
+
+ private string? Finish(MTConnectStreamEndReason reason)
+ {
+ _finished = true;
+ EndReason = reason;
+
+ return null;
+ }
+
+ private async Task ReadByLengthAsync(int declared, CancellationToken ct)
+ {
+ if (!await EnsureAsync(declared, ct).ConfigureAwait(false))
+ {
+ Finish(MTConnectStreamEndReason.ConnectionClosed);
+
+ return null;
+ }
+
+ var body = _buffer[..declared];
+ Consume(declared);
+
+ return body;
+ }
+
+ private async Task ReadToNextDelimiterAsync(CancellationToken ct)
+ {
+ var next = await FindDelimiterAsync(ct).ConfigureAwait(false);
+ if (next < 0)
+ {
+ // EOF with no closing delimiter: whatever is buffered is the last part. Returned
+ // rather than dropped, so a clean end of stream cannot strand a chunk.
+ var tail = _buffer[.._length];
+ _length = 0;
+ Finish(MTConnectStreamEndReason.ConnectionClosed);
+
+ return tail;
+ }
+
+ var body = _buffer[..TrimTrailingLineBreak(next)];
+
+ // Leave the delimiter itself in place; the next call's skip consumes it.
+ Consume(next);
+
+ return body;
+ }
+
+ /// Strips the single line break that terminates a part body before its delimiter.
+ private int TrimTrailingLineBreak(int end)
+ {
+ if (end >= 2 && _buffer[end - 2] == (byte)'\r' && _buffer[end - 1] == (byte)'\n')
+ {
+ return end - 2;
+ }
+
+ return end >= 1 && _buffer[end - 1] == (byte)'\n' ? end - 1 : end;
+ }
+
+ /// Advances past the next boundary delimiter; false at end of stream.
+ private async Task SkipThroughDelimiterAsync(CancellationToken ct)
+ {
+ var index = await FindDelimiterAsync(ct).ConfigureAwait(false);
+ if (index < 0)
+ {
+ return false;
+ }
+
+ Consume(index + _delimiter.Length);
+
+ return true;
+ }
+
+ private async Task FindDelimiterAsync(CancellationToken ct)
+ {
+ var searchedTo = 0;
+ while (true)
+ {
+ // Restart the scan far enough back that a delimiter split across two reads is still
+ // found — without the overlap, a boundary straddling a packet boundary is invisible.
+ var index = IndexOf(_delimiter, Math.Max(0, searchedTo - (_delimiter.Length - 1)));
+ if (index >= 0)
+ {
+ return index;
+ }
+
+ searchedTo = _length;
+ if (!await FillAsync(ct).ConfigureAwait(false))
+ {
+ return -1;
+ }
+ }
+ }
+
+ ///
+ /// Finds the end of the part's header block — the first blank line after the delimiter.
+ /// A part with no headers at all is legal, in which case the blank line sits at offset 0.
+ ///
+ private async Task FindHeaderEndAsync(CancellationToken ct)
+ {
+ var searchedTo = 0;
+ while (true)
+ {
+ var crlf = IndexOf(CrLfCrLf, Math.Max(0, searchedTo - 3));
+ var lf = IndexOf(LfLf, Math.Max(0, searchedTo - 1));
+
+ if (crlf >= 0 && (lf < 0 || crlf <= lf))
+ {
+ return crlf + CrLfCrLf.Length;
+ }
+
+ if (lf >= 0)
+ {
+ return lf + LfLf.Length;
+ }
+
+ searchedTo = _length;
+ if (!await FillAsync(ct).ConfigureAwait(false))
+ {
+ return -1;
+ }
+ }
+ }
+
+ private static int? ParseContentLength(string headers)
+ {
+ foreach (var line in headers.Split('\n'))
+ {
+ var trimmed = line.Trim();
+ if (!trimmed.StartsWith("content-length:", StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ var raw = trimmed["content-length:".Length..].Trim();
+
+ return int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value) &&
+ value >= 0
+ ? value
+ : null;
+ }
+
+ return null;
+ }
+
+ private async Task EnsureAsync(int count, CancellationToken ct)
+ {
+ while (_length < count)
+ {
+ if (!await FillAsync(ct).ConfigureAwait(false))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ ///
+ /// Reads at least one more byte, bounded by the heartbeat watchdog. Returns false
+ /// at end of stream and throws when the Agent goes silent.
+ ///
+ private async Task FillAsync(CancellationToken ct)
+ {
+ if (_length == _buffer.Length)
+ {
+ 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.");
+ }
+
+ Array.Resize(ref _buffer, Math.Min(_buffer.Length * 2, MaxBufferBytes));
+ }
+
+ using var watchdog = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ watchdog.CancelAfter(idleTimeout);
+
+ int read;
+ try
+ {
+ read = await stream.ReadAsync(_buffer.AsMemory(_length), watchdog.Token).ConfigureAwait(false);
+ }
+ 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.");
+ }
+
+ if (read <= 0)
+ {
+ return false;
+ }
+
+ _length += read;
+
+ return true;
+ }
+
+ private int IndexOf(byte[] pattern, int start)
+ {
+ if (start >= _length)
+ {
+ return -1;
+ }
+
+ var index = _buffer.AsSpan(start, _length - start).IndexOf(pattern.AsSpan());
+
+ return index < 0 ? -1 : index + start;
+ }
+
+ private void Consume(int count)
+ {
+ Buffer.BlockCopy(_buffer, count, _buffer, 0, _length - count);
+ _length -= count;
+ }
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs
new file mode 100644
index 00000000..9c60197c
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs
@@ -0,0 +1,3413 @@
+using System.Collections.Frozen;
+using System.Globalization;
+using System.Text.Json;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
+
+///
+/// MTConnect Agent implementation of — the lifecycle half. Everything the
+/// driver serves sits behind one , so the whole class is
+/// exercised against canned XML with no socket.
+///
+///
+///
+/// The constructor opens nothing and builds nothing. It does not even call
+/// agentClientFactory — the Wave-0 universal discovery browser constructs a throwaway
+/// instance purely to ask whether the driver can browse, and a constructor that materialized
+/// a client would open a connection pool per browse probe against a possibly-unreachable
+/// Agent. Every connection is owned by .
+///
+///
+/// The driverConfigJson argument is honoured, not decorative. The runtime hands
+/// a config change to a live instance — DriverInstanceActor's ApplyDelta
+/// calls with the new document and never rebuilds the object
+/// — so a driver that read only its constructor options would turn every MTConnect config
+/// edit into a silent no-op that still seals the deployment green. This class therefore
+/// re-parses the document () whenever it carries a real body,
+/// matching S7/AbCip/TwinCAT/Galaxy rather than the Modbus/FOCAS/OpcUaClient drivers that
+/// ignore the argument. A blank / {} / [] document means "no config supplied"
+/// and keeps the constructor options, which is what unit tests and the factory path rely on.
+///
+///
+/// Failure is never dressed as success (#485). A failed initialize disposes whatever
+/// client it built, drops the probe cache and the observation index, publishes
+/// with the error text, and rethrows — the runtime's
+/// retry/backoff loop is the recovery path. In particular a /probe that succeeds while
+/// the priming /current fails is Faulted, not "Healthy with no data": the index IS the
+/// read surface, so serving that state would render every tag
+/// BadWaitingForInitialData indefinitely behind a green driver, and the
+/// /sample cursor (Task 11) only exists because /current reported a
+/// nextSequence.
+///
+///
+/// Scope. This type implements , ,
+/// , ,
+/// and — the last two
+/// built on state this class already caches (the Agent instanceId and the probe
+/// model). There is deliberately no IWritable: MTConnect is a read-only protocol,
+/// which is also why every discovered leaf is
+/// .
+///
+///
+/// The read path never writes the shared observation index — see
+/// . That index has exactly one writer: the /sample pump
+/// (). The same single-writer discipline covers the two
+/// Task-13 fields: the connectivity probe loop is the sole writer of
+/// , and it publishes nothing into
+/// (see ).
+///
+///
+public sealed class MTConnectDriver : IDriver, IReadable, ISubscribable, ITagDiscovery, IHostConnectivityProbe, IRediscoverable
+{
+ ///
+ /// Coarse per-entry byte weights behind . The contract asks
+ /// for an approximate driver-attributable footprint that Core polls every 30 s to
+ /// decide whether to ask for a flush, so an order-of-magnitude estimate from the two things
+ /// that actually scale with a plant (declared data items, live observations) is the right
+ /// precision. Measuring real retained size would need a heap walk per poll.
+ ///
+ private const int ProbeModelBytesPerDataItem = 512;
+
+ /// Estimated retained bytes per live dataItemId → snapshot entry.
+ private const int IndexBytesPerEntry = 256;
+
+ /// Estimated retained bytes per authored .
+ private const int TagBytesPerEntry = 256;
+
+ ///
+ /// The Agent could not be reached, answered unusably, or blew the per-call deadline. Distinct
+ /// from the index's BadNoCommunication, which means the Agent answered and said the
+ /// machine has no value — the two failures need different operator responses.
+ ///
+ private const uint BadCommunicationError = 0x80050000u;
+
+ ///
+ /// There is no Agent client at all: the driver has not been initialized, its initialize
+ /// faulted, or it has been shut down. Deliberately not one of the index's codes — none of
+ /// them would say "this driver is not running", which is the only true statement available.
+ ///
+ private const uint BadNotConnected = 0x808A0000u;
+
+ ///
+ /// Floor the reconnect backoff grows from, in milliseconds. Load-bearing: the shipped
+ /// default is 0 (an immediate
+ /// first retry, which is what an operator wants after a one-off blip) and
+ /// 0 × multiplier is still 0 — so without a floor, the "geometric" backoff would be
+ /// an unbounded hot loop against an Agent that stays down. Mirrors
+ /// ModbusTcpTransport.ConnectWithBackoffAsync.
+ ///
+ private const int BackoffGrowthFloorMs = 100;
+
+ /// Growth factor used when the authored multiplier could not grow the delay (≤ 1).
+ private const double DefaultBackoffMultiplier = 2.0;
+
+ ///
+ /// Backoff cap used when is authored
+ /// non-positive. Treated as "unset" rather than as "no cap" for the same reason a
+ /// multiplier ≤ 1 falls back to : a zero cap clamps
+ /// EVERY delay to zero, which turns the reconnect loop into a spin against a refused
+ /// connection — one Warning per iteration, as fast as the socket can fail. Mirrors the
+ /// shipped default.
+ ///
+ private const int DefaultMaxBackoffMs = 30_000;
+
+ ///
+ /// How long teardown waits for a background loop (the /sample pump, the connectivity
+ /// probe) to unwind before abandoning the wait and carrying on.
+ ///
+ ///
+ /// An unbounded wait here is not merely slow — it wedges an actor-system thread.
+ /// DriverInstanceActor.PostStop calls with
+ /// GetAwaiter().GetResult(), i.e. a synchronous block on an Akka dispatcher thread,
+ /// while this driver holds . So a subscriber callback that never
+ /// returns would take the dispatcher thread AND every subsequent lifecycle call on this
+ /// driver with it, permanently. Five seconds matches the budget
+ /// DriverInstanceActor already puts around .
+ ///
+ private static readonly TimeSpan TeardownWait = TimeSpan.FromSeconds(5);
+
+ ///
+ /// Config-JSON reader options, mirroring the sibling driver factories. Note there is
+ /// deliberately noJsonStringEnumConverter: enum-carrying DTO fields stay
+ /// string? and go through , which is the project-wide
+ /// guard against the numerically-serialized-enum trap that faults a driver at deploy time.
+ ///
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ ReadCommentHandling = JsonCommentHandling.Skip,
+ AllowTrailingCommas = true,
+ };
+
+ ///
+ /// Reader options for the emptiness probe in . Kept in step with
+ /// so a document the real parse would accept is never judged
+ /// malformed here (a stray trailing comma must not change which branch a config takes).
+ ///
+ private static readonly JsonDocumentOptions DocumentOptions = new()
+ {
+ CommentHandling = JsonCommentHandling.Skip,
+ AllowTrailingCommas = true,
+ };
+
+ private readonly string _driverInstanceId;
+ private readonly ILogger _logger;
+ private readonly Func _agentClientFactory;
+
+ ///
+ /// How the connectivity probe loop waits between ticks. Injected so the cadence is a seam
+ /// rather than a wall clock: the loop is a timer, and a suite that drove it by sleeping would
+ /// be flaky by construction. Defaults to .
+ ///
+ private readonly Func _probeScheduler;
+
+ ///
+ /// Serializes the three lifecycle entry points against each other. Initialize / Reinitialize
+ /// / Shutdown all swap the client and the served caches; overlapping them would let a
+ /// shutdown dispose a client an in-flight initialize is about to publish.
+ ///
+ ///
+ ///
+ /// ⚠️ NON-REENTRANT — never call a public lifecycle method from code that already
+ /// holds this. has no owner tracking, so a re-entrant
+ /// call does not fail: it deadlocks the driver permanently, with no exception and
+ /// no log line to diagnose it from.
+ ///
+ ///
+ /// This is aimed squarely at the /sample pump (Task 11). Its ring-buffer
+ /// re-baseline ("on a sequence gap, re-/current and resume") is naturally written
+ /// as "just call the existing re-init logic" — and
+ /// / both take this
+ /// semaphore, so from inside the pump loop that is a hang, not a re-prime. Re-baseline
+ /// must instead call directly on the
+ /// client the pump already holds (through , to keep the
+ /// deadline) and Apply the result to . Any future
+ /// shared step belongs in a private, non-locking helper that both the pump and the
+ /// lock-holding lifecycle methods can call — never in the public entry points.
+ ///
+ ///
+ /// The inverse direction is already safe: the lifecycle methods call
+ /// while holding this, so that method — and whatever
+ /// Task 11 fills it with — must never re-enter the lifecycle either.
+ ///
+ ///
+ private readonly SemaphoreSlim _lifecycle = new(1, 1);
+
+ ///
+ /// Guards the subscription registry and the pump's control block (,
+ /// , ).
+ ///
+ ///
+ ///
+ /// Lock order is → this, never the reverse. The lifecycle
+ /// methods take this lock (to start/stop the pump) while holding the semaphore;
+ /// therefore releases this lock before awaiting
+ /// , and nothing under this lock ever waits on
+ /// .
+ ///
+ ///
+ /// Nothing is awaited while it is held, and no subscriber callback is raised under
+ /// it. A handler is caller code: it may block, throw, or re-enter this driver, and
+ /// holding a lock across it would turn a slow subscriber into a stalled pump.
+ ///
+ ///
+ private readonly Lock _subscriptionLock = new();
+
+ /// Live subscriptions by id. Guarded by .
+ private readonly Dictionary _subscriptions = [];
+
+ ///
+ /// Guards the two host-connectivity fields, which the probe loop writes and
+ /// reads from an arbitrary caller thread. Held for a field
+ /// read/write only — never across an await, and never while a status handler runs.
+ ///
+ private readonly Lock _probeLock = new();
+
+ ///
+ /// The Agent's last observed reachability. Written only by
+ /// , so the state and the event stream can never disagree:
+ /// every value this field has ever held was announced by exactly one
+ /// . That is why a successful
+ /// does not seed it — raising the event from there would run
+ /// caller code while the non-reentrant semaphore is held (the same
+ /// hazard that makes the pump, not , republish to subscribers),
+ /// and seeding it silently would leave a consumer that tracks the event stream disagreeing
+ /// with . until the first tick
+ /// is the honest answer. Guarded by .
+ ///
+ private HostState _hostState = HostState.Unknown;
+
+ /// When last changed. Guarded by .
+ private DateTime _hostStateChangedUtc = DateTime.UtcNow;
+
+ ///
+ /// Cancels the connectivity probe loop. Written only under , which
+ /// serializes every start and stop of the loop.
+ ///
+ private CancellationTokenSource? _probeCts;
+
+ /// The connectivity probe loop's task, or null when none runs. See .
+ private Task? _probeTask;
+
+ ///
+ /// The Agent instanceId the last was raised for —
+ /// seeded at the commit point of with the id the priming
+ /// /current reported. This is what makes rediscovery fire once per change
+ /// rather than once per chunk: every chunk after a restart carries the new id, and a
+ /// re-baseline that fails leaves the pump still holding the old one.
+ ///
+ private long _announcedInstanceId;
+
+ private MTConnectDriverOptions _options;
+
+ ///
+ /// The tag tables derived from — the RawPath ↔ dataItemId
+ /// translation the whole data plane runs through, plus the definition set the observation
+ /// index coerces against. Rebuilt (never mutated) whenever options are installed, and always
+ /// published in the same breath as them, so a lock-free reader can never pair one options
+ /// document's tags with another's RawPaths.
+ ///
+ private TagBinding _binding;
+ private MTConnectObservationIndex _index;
+ private DriverHealth _health = new(DriverState.Unknown, null, null);
+ private long _nextSubscriptionId;
+
+ /// Cancels the shared /sample pump. Guarded by .
+ private CancellationTokenSource? _pumpCts;
+
+ /// The shared /sample pump task. Guarded by .
+ private Task? _pumpTask;
+
+ ///
+ /// The Agent answered /sample with something that cannot be streamed at all, so the
+ /// pump gave up. Latched — reconnecting reproduces the identical answer forever, and a
+ /// later is not new information. Cleared only by a lifecycle
+ /// start, which is the one event that means an operator changed something. Guarded by
+ /// .
+ ///
+ private bool _streamUnsupported;
+
+ ///
+ /// Whether the /sample pump is currently failing. Kept separate from
+ /// because /sample and /current are different
+ /// Agent requests that fail independently — see .
+ ///
+ private volatile bool _streamDegraded;
+
+ /// Whether the /current read path is currently failing. See .
+ private volatile bool _readDegraded;
+
+ ///
+ /// Consecutive /sample reconnect attempts that have not yet been vindicated by
+ /// a delivered chunk — the input to . Reset by any stream that
+ /// actually delivered something, so an agent that drops a connection once an hour is never
+ /// treated as one that has been failing all day.
+ ///
+ private volatile int _reconnectAttempts;
+
+ ///
+ /// Latch behind the once-per-stream-generation subscriber-fault Warning. A consumer that
+ /// throws on every value would otherwise emit one Warning per observation per handle
+ /// — thousands a second on a busy Agent, which buries the very log it is trying to raise.
+ ///
+ private int _subscriberFaultLogged;
+
+ ///
+ /// The live agent client plus the /probe cache derived from it, held as one
+ /// immutable snapshot behind a single reference and only ever replaced by a
+ /// of a whole new instance — the same discipline
+ /// uses, and for the same reason.
+ ///
+ ///
+ ///
+ /// Why one field instead of three. These three values are read outside
+ /// — by , by
+ /// (Task 12's browse), and by
+ /// — while every write happens under it. As three
+ /// separate fields they could be observed mid-update: a reader could see a dropped
+ /// ProbeModel beside a stale non-zero data-item count and report a footprint for
+ /// a cache that no longer exists, or pair one initialize's client with the previous
+ /// one's model. Packing them makes every observation internally consistent by
+ /// construction, with no lock on the read path.
+ ///
+ ///
+ /// Why not simply take the lock in those readers. Both readers await the
+ /// network, so they would hold the lifecycle lock across an HTTP round trip — a browse
+ /// could stall a for a full RequestTimeoutMs — and
+ /// every added lock site widens the non-reentrancy hazard documented on
+ /// . It would also make the read path inconsistent with
+ /// , which is deliberately lock-free.
+ ///
+ ///
+ /// What this does NOT fix. A reader can still capture a session moments before a
+ /// concurrent teardown disposes its client, and then call it — no lock-free scheme can
+ /// prevent that, and holding the lock across the call is the trade rejected above.
+ /// What it guarantees is that the failure is named: the resulting
+ /// is caught at the seam and reported as the same
+ /// "driver is not connected" error a caller already has to handle, rather than escaping
+ /// raw out of a browse.
+ ///
+ ///
+ private AgentSession? _session;
+
+ /// Creates a driver instance. Opens no connection and builds no agent client.
+ ///
+ /// The typed configuration this instance starts from. A config document supplied to
+ /// / supersedes it.
+ ///
+ /// Stable logical id of this driver instance, from the config DB.
+ ///
+ /// Builds the Agent client from the effective options. Defaults to the production
+ /// ; tests inject a canned-XML fake. Called from
+ /// , never from this constructor.
+ ///
+ /// Optional logger; defaults to the null logger.
+ ///
+ /// How the connectivity probe loop waits between ticks. Defaults to
+ /// ; tests inject a hand-driven ticker so
+ /// the probe's cadence is asserted deterministically instead of slept through.
+ ///
+ public MTConnectDriver(
+ MTConnectDriverOptions options,
+ string driverInstanceId,
+ Func? agentClientFactory = null,
+ ILogger? logger = null,
+ Func? probeScheduler = null)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
+
+ _driverInstanceId = driverInstanceId;
+ _logger = logger ?? NullLogger.Instance;
+ _agentClientFactory = agentClientFactory ?? (o => new MTConnectAgentClient(o, logger));
+ _probeScheduler = probeScheduler ?? Task.Delay;
+
+ // Sets _options AND the tag tables derived from it. No /probe model is available here (the
+ // constructor dials nothing), so a raw tag whose blob declares no type falls back to String
+ // until InitializeAsync rebuilds the binding against the Agent's own declaration.
+ _options = options;
+ _binding = BuildBinding(options, probeModel: null);
+
+ // Never null, so every consumer (Task 10's ReadAsync included) can ask it for a snapshot
+ // without a null check: an un-primed index answers BadWaitingForInitialData for an authored
+ // tag and BadNodeIdUnknown otherwise, which is exactly the truth before Initialize runs.
+ _index = new MTConnectObservationIndex(_binding.IndexTags);
+ }
+
+ ///
+ public string DriverInstanceId => _driverInstanceId;
+
+ ///
+ ///
+ /// Sourced from the constant rather than a literal — the
+ /// repo-wide fix for the historical TwinCat/Focas drift, where a driver's own
+ /// spelling diverged from the constant every dispatch map keys off.
+ ///
+ public string DriverType => DriverTypeNames.MTConnect;
+
+ ///
+ /// The live dataItemId → snapshot map that backs the subscription surface:
+ /// primed by and thereafter written only by the /sample
+ /// pump (Task 11). Exposed to tests as the observable proof that initialize actually primed
+ /// it — and, in MTConnectReadTests, that leaves it alone.
+ ///
+ ///
+ /// Read does not consume this. indexes its own /current
+ /// into a per-call snapshot instead, so the pump keeps a single writer — see the failure
+ /// analysis in 's remarks.
+ ///
+ internal MTConnectObservationIndex ObservationIndex => Volatile.Read(ref _index);
+
+ ///
+ /// The cached /probe device model, or null when it has not been fetched or was
+ /// dropped by . Prefer
+ /// , which cannot observe the flushed hole.
+ ///
+ internal MTConnectProbeModel? CachedProbeModel => Volatile.Read(ref _session)?.ProbeModel;
+
+ ///
+ /// The Agent's instanceId as of the last successful /current — including the
+ /// re-baseline the /sample pump runs when it sees the id change mid-stream. Task 13
+ /// watches this for change to raise rediscovery (an Agent restart invalidates every cached
+ /// id).
+ ///
+ internal long? AgentInstanceId => Volatile.Read(ref _session)?.InstanceId;
+
+ ///
+ /// The sequence the next /sample stream will open at — the priming /current's
+ /// nextSequence, advanced by every re-baseline and by the pump as it stops. Paired
+ /// with in one session record, so the two are never observed
+ /// out of step.
+ ///
+ internal long? AgentNextSequence => Volatile.Read(ref _session)?.NextSequence;
+
+ ///
+ /// Consecutive /sample reconnect attempts not yet vindicated by a delivered chunk —
+ /// the input to , exposed so the reset policy can be asserted
+ /// without measuring how long anything slept.
+ ///
+ internal int ReconnectAttempts => _reconnectAttempts;
+
+ ///
+ /// The shared /sample pump's task while one is running, else null. Exposed to
+ /// tests as the deterministic teardown barrier: "the pump has stopped" is a task completion,
+ /// never a sleep. Guarded, so a test never observes a half-installed pump.
+ ///
+ internal Task? SampleStreamTask
+ {
+ get
+ {
+ lock (_subscriptionLock)
+ {
+ return _pumpTask;
+ }
+ }
+ }
+
+ ///
+ /// The connectivity probe loop's task while one is running, else null. Exposed to
+ /// tests as the deterministic teardown barrier — "the probe loop has stopped" is a task
+ /// completion, never a sleep — and as the proof that Probe.Enabled = false starts
+ /// nothing at all.
+ ///
+ internal Task? ProbeLoopTask => Volatile.Read(ref _probeTask);
+
+ /// The options currently in force — the constructor's, or the last document applied.
+ internal MTConnectDriverOptions EffectiveOptions => _options;
+
+ ///
+ ///
+ /// Builds the Agent client from the effective options, runs one deadline-bounded
+ /// /probe (caching the device model and its data-item count), then one
+ /// deadline-bounded /current to prime the observation index and record the Agent's
+ /// instanceId. Publishes on success; on any failure
+ /// disposes the client, clears the caches, publishes with
+ /// the error text, and rethrows.
+ ///
+ public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
+ {
+ await _lifecycle.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ // Parse BEFORE tearing down — see ResolveIncomingOptions. Initialize is reachable on a
+ // live instance (DriverInstanceActor re-initialises in Connecting/Reconnecting), and
+ // destroying a working client before discovering the new document is unreadable would
+ // be the exact outcome ReinitializeAsync is written to avoid.
+ var options = ResolveIncomingOptions(driverConfigJson);
+
+ // A re-Initialize on a live instance must not orphan the previous client — nor the pump
+ // that is enumerating it. Stopping the stream FIRST is the same ordering
+ // ReinitializeAsync and ShutdownAsync use, and for the same reason: a pump left
+ // enumerating a disposed client reads the disposal as a dropped connection and
+ // reconnects against an object that no longer exists.
+ await StopSampleStreamAsync(cancellationToken).ConfigureAwait(false);
+ await StopProbeLoopAsync(cancellationToken).ConfigureAwait(false);
+ await TeardownCoreAsync().ConfigureAwait(false);
+
+ await StartCoreAsync(options, existingClient: null, cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ _lifecycle.Release();
+ }
+ }
+
+ ///
+ ///
+ ///
+ /// Stops the /sample stream, then takes one of two paths depending on whether the
+ /// new document changes anything the reads at
+ /// construction:
+ ///
+ ///
+ /// Endpoint/transport changed (AgentUri, DeviceName,
+ /// RequestTimeoutMs, HeartbeatMs, SampleIntervalMs,
+ /// SampleCount) ⇒ full teardown and rebuild. The plan names only the first two,
+ /// but the other four are baked into the client's deadlines, its watchdog window, and its
+ /// /sample query string, so keeping the old client because the URI happened not to
+ /// change would silently discard the operator's edit — the same class of defect as
+ /// ignoring the config document altogether.
+ ///
+ ///
+ /// Otherwise ("config-only": authored tags, probe cadence, reconnect backoff) ⇒
+ /// the live client and its connection pool are kept, and everything derived from config
+ /// is rebuilt: the probe model is re-fetched, the observation index is recreated against
+ /// the new tag types, and one /current re-primes it. Nothing config-derived
+ /// survives, so a retyped tag cannot keep coercing to its old type.
+ ///
+ ///
+ public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
+ {
+ await _lifecycle.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ var incoming = ResolveIncomingOptions(driverConfigJson);
+
+ var existing = Volatile.Read(ref _session)?.Client;
+
+ await StopSampleStreamAsync(cancellationToken).ConfigureAwait(false);
+ await StopProbeLoopAsync(cancellationToken).ConfigureAwait(false);
+
+ if (existing is null || RequiresClientRebuild(_options, incoming))
+ {
+ await TeardownCoreAsync().ConfigureAwait(false);
+ await StartCoreAsync(incoming, existingClient: null, cancellationToken).ConfigureAwait(false);
+
+ return;
+ }
+
+ await StartCoreAsync(incoming, existing, cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ _lifecycle.Release();
+ }
+ }
+
+ ///
+ ///
+ /// Stops the /sample stream, disposes the Agent client, and drops the probe cache and
+ /// the observation index — values sourced from a torn-down connection must never keep
+ /// reading Good. LastSuccessfulRead is retained because it is diagnostic ("when did
+ /// this driver last hear from the Agent"), not served data. Idempotent.
+ ///
+ public async Task ShutdownAsync(CancellationToken cancellationToken)
+ {
+ await _lifecycle.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ var lastRead = ReadHealth().LastSuccessfulRead;
+
+ await StopSampleStreamAsync(cancellationToken).ConfigureAwait(false);
+ await StopProbeLoopAsync(cancellationToken).ConfigureAwait(false);
+ await TeardownCoreAsync().ConfigureAwait(false);
+
+ WriteHealth(new DriverHealth(DriverState.Unknown, lastRead, null));
+ }
+ finally
+ {
+ _lifecycle.Release();
+ }
+ }
+
+ ///
+ public DriverHealth GetHealth() => ReadHealth();
+
+ ///
+ ///
+ /// Sums the three caches that scale with plant size: the /probe device model (by
+ /// declared data item), the live observation index (by indexed data item), and the authored
+ /// tag table. See for why the weights are coarse
+ /// constants.
+ ///
+ public long GetMemoryFootprint() =>
+ ((long)(Volatile.Read(ref _session)?.ProbeModelDataItemCount ?? 0) * ProbeModelBytesPerDataItem)
+ + ((long)ObservationIndex.Count * IndexBytesPerEntry)
+ + ((long)Volatile.Read(ref _binding).IndexTags.Count * TagBytesPerEntry);
+
+ ///
+ ///
+ /// Drops the /probe device model — the browse cache, which is re-derivable from the
+ /// Agent at any time — and keeps the observation index, which is required for correctness
+ /// (it is the read surface, and re-deriving it would mean a full re-prime the caller did not
+ /// ask for). Flushing cannot wedge the driver: every consumer of the model goes through
+ /// , which re-fetches and re-caches on a miss.
+ ///
+ /// The drop is a single of a whole new
+ /// , so the model and its data-item count can never be seen
+ /// out of step (which would skew ), and a flush that
+ /// races a concurrent teardown or re-initialize is discarded rather than resurrecting
+ /// the session it was reading.
+ ///
+ ///
+ public Task FlushOptionalCachesAsync(CancellationToken cancellationToken)
+ {
+ var session = Volatile.Read(ref _session);
+ if (session?.ProbeModel is null)
+ {
+ return Task.CompletedTask;
+ }
+
+ var flushed = session with { ProbeModel = null, ProbeModelDataItemCount = 0 };
+ if (!ReferenceEquals(Interlocked.CompareExchange(ref _session, flushed, session), session))
+ {
+ // Someone swapped the session while we were building the flushed copy — their state is
+ // newer than ours, and publishing would undo it. Whatever they installed is either a
+ // teardown (no cache to flush) or a fresh initialize (a cache the operator's budget
+ // breach never saw), so dropping this flush is correct, not merely safe.
+ return Task.CompletedTask;
+ }
+
+ _logger.LogInformation(
+ "MTConnect driver {DriverInstanceId} flushed its cached /probe device model ({DataItemCount} data items); it is re-fetched on the next browse.",
+ _driverInstanceId, session.ProbeModelDataItemCount);
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ ///
+ ///
+ /// One /current per batch, whatever the batch size. MTConnect's
+ /// /current answers for the whole device (or the whole Agent), so N references
+ /// cost one deadline-bounded round-trip and are answered out of that one document. There
+ /// is no per-reference request to make and none is made.
+ ///
+ ///
+ /// The response is indexed into a per-call snapshot, NOT into the driver's shared
+ /// observation index. That index is written by the /sample pump (Task 11) and
+ /// is deliberately last-write-wins with no timestamp arbitration. A read that folded its
+ /// /current into it would be a second writer racing the first, and the
+ /// /current document is frequently the older of the two — the Agent
+ /// composes it while the pump's newer delta is already in flight. Losing that race rolls
+ /// a subscribed value backwards and leaves it there until the data item next
+ /// changes, which for an EVENT such as Execution can be hours. Keeping the
+ /// read path a pure function of (document, authored tags) costs one index construction
+ /// per batch — negligible beside the HTTP round-trip that precedes it — and makes it
+ /// impossible for a read to corrupt subscription state. The reference source-of-truth is
+ /// unchanged either way: both paths coerce through the same
+ /// logic, so a read and a subscription report a
+ /// given observation identically.
+ ///
+ ///
+ /// Nothing but genuine caller cancellation crosses this boundary. An unreachable
+ /// Agent, a blown deadline, an unusable answer — each fails the batch's values
+ /// (every reference codes ) rather than the batch: an
+ /// exception here would also fail the references that were perfectly readable, and the
+ /// node-manager cannot tell a thrown read from a broken driver. This is the opposite
+ /// posture to , which is specified to throw so the
+ /// runtime's retry/backoff loop can own recovery.
+ ///
+ ///
+ /// The status-code distinctions the index draws — BadWaitingForInitialData for an
+ /// authored-but-unreported id, BadNodeIdUnknown for an unknown one,
+ /// BadNoCommunication for the Agent's UNAVAILABLE — are passed through
+ /// unmodified. They tell an operator three different things.
+ ///
+ ///
+ ///
+ /// is null — a caller bug, not device data, and the
+ /// one thing this method is loud about (matching 's
+ /// documented posture).
+ ///
+ public async Task> ReadAsync(
+ IReadOnlyList fullReferences, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(fullReferences);
+
+ // Checked before anything else: an empty batch must cost the Agent nothing, and there is no
+ // failure it could report.
+ if (fullReferences.Count == 0)
+ {
+ return [];
+ }
+
+ // One read of the session reference: the client used below is the one that was live at this
+ // instant, never a half-swapped pairing (see the _session remarks).
+ var client = Volatile.Read(ref _session)?.Client;
+ var options = _options;
+
+ // Read once, for the same reason the session is: every reference in this batch must be
+ // resolved against ONE tag table, not half against the table a concurrent re-initialize
+ // just replaced.
+ var binding = Volatile.Read(ref _binding);
+
+ if (client is null)
+ {
+ // Before InitializeAsync, after a faulted one, or after ShutdownAsync. Health is left
+ // exactly as it is: a read attempted against a driver that was never started (or was
+ // deliberately stopped) is not evidence of degradation.
+ return BadBatch(fullReferences.Count, BadNotConnected);
+ }
+
+ try
+ {
+ var current = await BoundedAsync(client.CurrentAsync, "/current", options.RequestTimeoutMs, cancellationToken)
+ .ConfigureAwait(false);
+
+ // Built from the SAME authored tags as the shared index, so the authored-vs-unknown
+ // distinction and every coercion behave identically — it is a snapshot, not a variant.
+ var snapshot = new MTConnectObservationIndex(binding.IndexTags);
+ snapshot.Apply(current);
+
+ var results = new DataValueSnapshot[fullReferences.Count];
+ for (var i = 0; i < results.Length; i++)
+ {
+ // Each reference is a RawPath, translated to the DataItem id the index is keyed by.
+ // An unmapped reference falls through unchanged and misses — Bad, never a throw.
+ // Get never throws and never returns null, including for a null/blank reference.
+ results[i] = snapshot.Get(binding.ToDataItemId(fullReferences[i]));
+ }
+
+ _readDegraded = false;
+ PublishHealthy(DateTime.UtcNow);
+
+ return results;
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ // The caller asked us to stop. This is the ONLY exception that may propagate: a blown
+ // per-call deadline arrives as the TimeoutException BoundedAsync raises instead, and is
+ // handled below as the Agent failure it is.
+ throw;
+ }
+ catch (Exception ex)
+ {
+ DegradeAfterReadFailure(ex);
+
+ return BadBatch(fullReferences.Count, BadCommunicationError);
+ }
+ }
+
+ // ---- ISubscribable: ONE shared /sample long poll behind every handle ----
+
+ ///
+ public event EventHandler? OnDataChange;
+
+ ///
+ ///
+ ///
+ /// Initial data comes from the primed /current, not from the wire. Every
+ /// subscribed reference gets one callback immediately (the OPC UA Part 4 convention),
+ /// answered out of the observation index — including the references with no value yet,
+ /// which report BadWaitingForInitialData. Waiting for the Agent's next chunk
+ /// instead would leave a fresh subscription silent for up to a whole heartbeat,
+ /// and permanently silent for any data item that simply is not changing.
+ ///
+ ///
+ /// Subscribe does not wait for the stream. The first subscription starts the
+ /// shared pump; the pump then opens /sample on its own task under its own
+ /// cancellation token. This is deliberate on both counts. The opening handshake cannot
+ /// be awaited inside the caller's Subscribe timeout in any useful sense — the first
+ /// chunk may legitimately be a heartbeat away — and the long-lived poll must NOT run
+ /// under the caller's token, which belongs to one Subscribe call and is disposed the
+ /// moment it returns. What bounds the running stream is the client's own heartbeat
+ /// watchdog, and what stops it is or the lifecycle.
+ ///
+ ///
+ /// is not honoured per subscription, and
+ /// cannot be: MTConnect's publish cadence is the Agent-side interval query
+ /// parameter of the single shared /sample request
+ /// (), fixed when the client is
+ /// built. Honouring a per-handle interval would mean one stream per subscription — the
+ /// Agent load this driver exists to avoid — or client-side throttling that would drop
+ /// transient EVENT values the Agent bothered to send. The parameter is accepted and
+ /// ignored, which is also what the natively-subscribing sibling drivers do.
+ ///
+ ///
+ /// Subscribing before is legal: the interest is recorded,
+ /// every reference reports "no value yet", and the initialize that follows starts the
+ /// pump for it. It does not throw and it does not dial an Agent that is not there yet.
+ ///
+ ///
+ ///
+ /// is null — a caller bug, and the only thing this
+ /// method is loud about, matching 's posture.
+ ///
+ public Task SubscribeAsync(
+ IReadOnlyList fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(fullReferences);
+
+ // De-duplicated: a reference asked for twice in one subscription is one subscribed value,
+ // and would otherwise raise two identical callbacks per chunk forever.
+ var references = new HashSet(StringComparer.Ordinal);
+ foreach (var reference in fullReferences)
+ {
+ if (reference is not null)
+ {
+ references.Add(reference);
+ }
+ }
+
+ var handle = new MTConnectSampleHandle(Interlocked.Increment(ref _nextSubscriptionId));
+
+ lock (_subscriptionLock)
+ {
+ _subscriptions[handle.SubscriptionId] = new SubscriptionState(handle, references);
+
+ // No-op when a pump is already running: every handle shares the one stream.
+ StartSampleStreamCore(republishOnStart: false);
+ }
+
+ // Raised OUTSIDE the lock — a handler is caller code (see the _subscriptionLock remarks).
+ // The callback carries the caller's OWN reference (the RawPath); only the index lookup
+ // behind it is translated to the DataItem id.
+ var index = ObservationIndex;
+ var binding = Volatile.Read(ref _binding);
+ foreach (var reference in references)
+ {
+ RaiseDataChange(handle, reference, index.Get(binding.ToDataItemId(reference)));
+ }
+
+ _logger.LogDebug(
+ "MTConnect driver {DriverInstanceId} opened subscription {SubscriptionId} over {ReferenceCount} reference(s).",
+ _driverInstanceId, handle.DiagnosticId, references.Count);
+
+ return Task.FromResult(handle);
+ }
+
+ ///
+ ///
+ /// Drops the handle's references and stops the shared stream when the last subscription
+ /// goes. An unknown handle — already unsubscribed, or issued by a different driver — is a
+ /// no-op rather than an error: this runs on teardown paths, including failure paths, where
+ /// throwing would mask whatever prompted the teardown.
+ ///
+ /// is null.
+ public async Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(handle);
+
+ if (handle is not MTConnectSampleHandle ours)
+ {
+ return;
+ }
+
+ bool stopStream;
+ lock (_subscriptionLock)
+ {
+ if (!_subscriptions.Remove(ours.SubscriptionId))
+ {
+ return;
+ }
+
+ stopStream = !HasSubscribedReferencesCore();
+ }
+
+ if (stopStream)
+ {
+ // Outside the lock: StopSampleStreamAsync awaits the pump, and the pump takes this same
+ // lock to read the subscription registry. Holding it here would deadlock the two.
+ // The caller's token bounds the wait: DriverInstanceActor puts a 5 s budget around this
+ // call, and until now that budget was inert.
+ await StopSampleStreamAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ _logger.LogDebug(
+ "MTConnect driver {DriverInstanceId} closed subscription {SubscriptionId}{StreamNote}.",
+ _driverInstanceId, ours.DiagnosticId, stopStream ? " and stopped the shared /sample stream" : string.Empty);
+ }
+
+ // ---- ITagDiscovery: the /probe device model, streamed as folders + variables ----
+
+ ///
+ ///
+ /// This one member is the whole browse opt-in. The Wave-0 universal
+ /// DiscoveryDriverBrowser turns any driver that answers true here into a live
+ /// address picker by capturing — so this driver ships no browser
+ /// code, no per-driver picker component, and no entry in the browser's browse-patch table
+ /// (its discovery is unconditional; there is no "enable browse" option to turn on first).
+ /// It is answered on an un-initialized instance without dialling anything, because
+ /// CanBrowse constructs a throwaway driver purely to read it — which is also why the
+ /// constructor opens nothing.
+ ///
+ public bool SupportsOnlineDiscovery => true;
+
+ ///
+ ///
+ /// Once, not the default UntilStable: streams the
+ /// complete device model within the single call — /probe is one synchronous request
+ /// that either answers with the whole hierarchy or fails — so nothing fills in asynchronously
+ /// after connect and a settle loop would only re-fetch an identical answer. (The one thing
+ /// that does change the model is an Agent restart, and that is
+ /// IRediscoverable's job in Task 13, not a retry cadence's.)
+ ///
+ public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once;
+
+ ///
+ ///
+ ///
+ /// Streams the Agent's /probe hierarchy verbatim: one folder per Device,
+ /// one per nested Component to arbitrary depth, and one variable per
+ /// DataItem under whichever container declares it — including the data items a
+ /// device declares directly, outside any component (an availability item almost
+ /// always lives there, and a walker that only recursed Components would silently
+ /// drop it).
+ ///
+ ///
+ /// is the DataItem@id, never the
+ /// browse name. The universal browser commits a browsed leaf's FullName
+ /// straight into TagConfig.FullName, and that is the key
+ /// and the /sample pump resolve an observation against
+ /// ( is keyed by DataItem@id). Committing
+ /// the name instead would produce a picker that looks right and authors tags that
+ /// can never report a value. The browse name — cosmetic — prefers the DataItem's
+ /// name and falls back to its id, which for this protocol is the common
+ /// case rather than an edge case.
+ ///
+ ///
+ /// The type shape comes from 's returned
+ /// record, in full — and
+ /// included. Recomputing the array shape
+ /// locally from representation/sampleCount is the tempting shortcut and it
+ /// is wrong: the inference also demotes DATA_SET/TABLE to
+ /// even on a SAMPLE, and honours
+ /// TIME_SERIES only on a SAMPLE. The AdminUI typed editor calls the same
+ /// function, so any divergence here makes the picker and the editor disagree about one
+ /// data item. The type attribute is passed verbatim for the same reason:
+ /// the probe document spells it UPPER_SNAKE (PART_COUNT) where the streams
+ /// document uses PascalCase, and the inference is separator-insensitive precisely to
+ /// bridge the two — pre-normalising it here would defeat that.
+ ///
+ ///
+ /// is this seam's job, deliberately not
+ /// the inference's: a CONDITION data item is an alarm.
+ /// IVariableHandle.MarkAsAlarmCondition is not called — that annotation
+ /// needs the driver-side refs of an alarm's sibling attributes (in-alarm flag, priority,
+ /// ack target), and an MTConnect condition has none: it is one text observation whose
+ /// value is the state word. Core's generic node manager enriches on the flag alone.
+ ///
+ ///
+ /// The model comes from , never from
+ /// . may have
+ /// dropped the cache under memory pressure; reading the field directly would make a
+ /// flushed driver browse as an Agent with no data items, permanently.
+ ///
+ ///
+ /// This throws, and that is the opposite of 's posture on
+ /// purpose. A read has a per-reference Bad status code to report a failure in-band; a
+ /// discovery stream has no such channel, so the only way to "fail quietly" would be to
+ /// emit an empty tree — indistinguishable from an Agent that genuinely declares nothing,
+ /// and read by an operator in the picker as a working connection to an empty machine
+ /// (#485: empty is not an answer).
+ ///
+ ///
+ /// What the callers actually do with the throw — worth stating precisely, because
+ /// the reassuring version ("it is logged and retried") is not true here. The live
+ /// consumer is the /raw browse-commit path
+ /// (DiscoveryDriverBrowser/BrowserSessionService), which surfaces it to the
+ /// operator as a failed browse — that is the case this posture is chosen for.
+ /// DriverInstanceActor.HandleRediscoverAsync catches it, logs a Warning, and
+ /// publishes an EMPTY node set; it does not reschedule, because retrying is a
+ /// behaviour and this driver is
+ /// . And that publish currently reaches
+ /// nothing: DriverHostActor.HandleDiscoveredNodes short-circuits unconditionally
+ /// (discovered-node injection is dormant in v3 — raw tags are authored through
+ /// browse-commit instead), so at that seam the throw-versus-empty choice has no runtime
+ /// effect today. It is made for the browse path, and for whatever re-enables injection.
+ ///
+ ///
+ /// Nothing here writes the observation index. Discovery is a pure read of the
+ /// device model — the /sample pump remains its sole writer.
+ ///
+ ///
+ /// is null.
+ ///
+ /// The driver is not initialized, or it was torn down while the /probe fetch was in
+ /// flight (see ).
+ ///
+ public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(builder);
+
+ var model = await GetOrFetchProbeModelAsync(cancellationToken).ConfigureAwait(false);
+
+ // Read once: a concurrent re-initialize must not scope half this walk to one device and half
+ // to another.
+ var deviceScope = _options.DeviceName?.Trim();
+
+ var devices = 0;
+ var variables = 0;
+
+ foreach (var device in model.Devices)
+ {
+ if (device is null || !InDeviceScope(device, deviceScope))
+ {
+ continue;
+ }
+
+ var name = FolderName(device.Name, device.Id);
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ // Neither a name nor an id: there is no browse path that could identify this device,
+ // and folding it in would emit a blank tree segment (and a blank NodeId component
+ // downstream). Its data items are unreachable either way.
+ _logger.LogWarning(
+ "MTConnect driver {DriverInstanceId} skipped a device in {AgentUri}'s /probe model that declares neither a name nor an id; it cannot be given a browse path.",
+ _driverInstanceId, _options.AgentUri);
+
+ continue;
+ }
+
+ devices++;
+ variables += StreamContainer(device, builder.Folder(name, name));
+ }
+
+ // A configured scope that matches NO device is an authoring error — almost always a typo or
+ // a device renamed in the Agent — and its only symptom is a browse that succeeds and shows
+ // nothing. Reported at Warning, because at Debug the operator's evidence is an empty picker
+ // and no explanation. An agent-wide browse that finds nothing is a different statement (the
+ // Agent declares no devices) and stays a Debug tally.
+ if (devices == 0 && !string.IsNullOrEmpty(deviceScope))
+ {
+ _logger.LogWarning(
+ "MTConnect driver {DriverInstanceId} is scoped to device '{DeviceScope}', but {AgentUri}'s /probe model declares no device with that name or id ({DeclaredCount} declared). The browse is empty because the scope matched nothing, not because the Agent has nothing.",
+ _driverInstanceId, deviceScope, _options.AgentUri, model.Devices.Count);
+
+ return;
+ }
+
+ _logger.LogDebug(
+ "MTConnect driver {DriverInstanceId} streamed {DeviceCount} device(s) and {VariableCount} data item(s) from {AgentUri}'s /probe model{DeviceScope}.",
+ _driverInstanceId,
+ devices,
+ variables,
+ _options.AgentUri,
+ deviceScope is null or "" ? string.Empty : $" (scoped to '{deviceScope}')");
+ }
+
+ ///
+ /// Streams one device/component's own data items into , then recurses
+ /// into each nested component under a folder of its own. Returns how many variables it
+ /// streamed, for the diagnostic tally.
+ ///
+ ///
+ /// Walks the tree rather than using AllDataItems(): that helper flattens, and the
+ /// folder structure — which item belongs to which component — is precisely what a browse
+ /// picker exists to show. Recursion depth is bounded by the parser, which refuses a probe
+ /// document nested beyond its own limit.
+ ///
+ private static int StreamContainer(IMTConnectComponentContainer container, IAddressSpaceBuilder scope)
+ {
+ var streamed = 0;
+
+ foreach (var dataItem in container.DataItems)
+ {
+ // An id-less data item cannot be read, subscribed, or committed as a tag — there would be
+ // nothing to key it by. The parser already requires the attribute; this is the guard for a
+ // model built any other way.
+ if (dataItem is null || string.IsNullOrWhiteSpace(dataItem.Id))
+ {
+ continue;
+ }
+
+ // Every field the DataItem declares, straight through — the array shape is READ OFF the
+ // result, never recomputed here. See the DiscoverAsync remarks.
+ var inferred = MTConnectDataTypeInference.Infer(
+ dataItem.Category,
+ dataItem.Type,
+ dataItem.Units,
+ dataItem.Representation,
+ dataItem.SampleCount);
+
+ var browseName = string.IsNullOrWhiteSpace(dataItem.Name) ? dataItem.Id : dataItem.Name;
+
+ scope.Variable(
+ browseName,
+ browseName,
+ new DriverAttributeInfo(
+ FullName: dataItem.Id,
+ DriverDataType: inferred.DataType,
+ IsArray: inferred.IsArray,
+ ArrayDim: inferred.ArrayDim,
+
+ // MTConnect is read-only and this driver implements no IWritable, so the tier
+ // that is read-only from OPC UA is the only honest one.
+ SecurityClass: SecurityClassification.ViewOnly,
+
+ // Historization is authored per tag, never inferred from a device model.
+ IsHistorized: false,
+ IsAlarm: IsCondition(dataItem.Category)));
+
+ streamed++;
+ }
+
+ foreach (var component in container.Components)
+ {
+ if (component is null)
+ {
+ continue;
+ }
+
+ // Guarded on the RESOLVED folder name, not on the id alone: a component with a blank id
+ // but a real name browses perfectly well (the id is only the fallback), while one with
+ // neither would open a folder named "" — a blank segment in every browse path beneath
+ // it. The data-item guard above is stricter for a different reason: THAT id is the
+ // correlation key an observation is resolved by, so a blank one is unreadable whatever
+ // it is called.
+ var name = FolderName(component.Name, component.Id);
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ continue;
+ }
+
+ streamed += StreamContainer(component, scope.Folder(name, name));
+ }
+
+ return streamed;
+ }
+
+ ///
+ /// Whether a device is in this instance's configured scope. An unset scope is agent-wide.
+ ///
+ ///
+ ///
+ /// Matched against the device's nameor its id — a device model may
+ /// omit the name, and its id is then what an operator would put in the config and what a
+ /// scoped Agent URL would carry.
+ ///
+ ///
+ /// Belt-and-braces, not dead code. The production client already narrows the
+ /// request to {AgentUri}/{DeviceName}/probe, so a conforming Agent answers with the
+ /// one device anyway. But an Agent (or an intermediary) that ignored the path segment and
+ /// answered agent-wide would otherwise publish every other machine's data items into an
+ /// instance scoped to one, and the operator's only evidence would be a picker full of ids
+ /// they never configured. Matching is exact for the same reason the URL is: a
+ /// differently-cased device name is not the device the Agent would have served.
+ ///
+ ///
+ private static bool InDeviceScope(MTConnectDevice device, string? deviceScope) =>
+ string.IsNullOrEmpty(deviceScope)
+ || string.Equals(device.Name, deviceScope, StringComparison.Ordinal)
+ || string.Equals(device.Id, deviceScope, StringComparison.Ordinal);
+
+ /// The folder name for a device/component: its name, or its id when it declares none.
+ private static string FolderName(string? name, string id) => string.IsNullOrWhiteSpace(name) ? id : name;
+
+ ///
+ /// Whether a data item's category makes it an alarm. Lives here rather than in
+ /// because that function answers a type
+ /// question; this is an address-space one.
+ ///
+ private static bool IsCondition(string? category) =>
+ category.AsSpan().Trim().Equals("CONDITION", StringComparison.OrdinalIgnoreCase);
+
+ // ---- IHostConnectivityProbe: one host per Agent, on its own cheap tick ----
+
+ ///
+ ///
+ /// Raised from the probe loop's task, never under a lock and never under
+ /// . A handler is arbitrary caller code: one that blocks forever
+ /// blocks the loop with it (and therefore the teardown that awaits it), exactly as an
+ /// handler does to the pump. One that throws is absorbed.
+ ///
+ public event EventHandler? OnHostStatusChanged;
+
+ ///
+ /// The single host this driver instance talks to, named by its configured
+ /// . Mirrors ModbusDriver.HostName's
+ /// host:port convention: several MTConnect drivers in one server disambiguate on the
+ /// Admin dashboard by endpoint rather than by driver-instance id.
+ ///
+ public string HostName => _options.AgentUri;
+
+ ///
+ ///
+ /// One entry, always — an Agent is a single host, and the driver knows of it whether or not
+ /// it has been reached. Before the first probe tick (and for the whole life of an instance
+ /// with Probe.Enabled = false) that entry reads , which
+ /// is the truth: nothing has measured it. See for why a successful
+ /// initialize does not seed it .
+ ///
+ public IReadOnlyList GetHostStatuses()
+ {
+ lock (_probeLock)
+ {
+ return [new HostConnectivityStatus(HostName, _hostState, _hostStateChangedUtc)];
+ }
+ }
+
+ ///
+ /// Starts the periodic connectivity probe, if the operator asked for one. Caller must hold
+ /// .
+ ///
+ /// The client this session owns; the loop dials that one, never a later one.
+ /// The options this session started with.
+ private void StartProbeLoopCore(IMTConnectAgentClient client, MTConnectDriverOptions options)
+ {
+ if (!options.Probe.Enabled)
+ {
+ return;
+ }
+
+ var cts = new CancellationTokenSource();
+ _probeCts = cts;
+ Volatile.Write(ref _probeTask, Task.Run(() => RunProbeLoopAsync(client, options, cts.Token), CancellationToken.None));
+ }
+
+ ///
+ /// The connectivity probe: wait, ask the Agent whether it is answering, publish the verdict,
+ /// repeat. Never throws — it is a detached background task, and an escaping exception would
+ /// silently end host reporting while kept serving the last
+ /// thing it saw.
+ ///
+ ///
+ ///
+ /// It calls directly and publishes
+ /// nothing. Both halves of that are deliberate.
+ ///
+ ///
+ /// Routing it through is the obvious shortcut and
+ /// it is inert: that accessor answers from the cache
+ /// already warmed, so the loop would issue no request at all and report
+ /// forever no matter what the Agent was doing — a feature
+ /// that ticks, logs, and measures nothing.
+ ///
+ ///
+ /// Publishing the answer into is the opposite
+ /// mistake: it would put a second writer on a field the lifecycle owns under a
+ /// compare-and-swap, and would silently re-warm a cache that
+ /// — or an Agent restart, see
+ /// — deliberately dropped. The parsed model is
+ /// discarded; only the fact that a request completed is used.
+ ///
+ ///
+ /// Why /probe rather than the /sample heartbeat. The heartbeat is
+ /// free, but it only exists while something is subscribed — an instance with no
+ /// subscriptions would report indefinitely, and host
+ /// reachability would become a function of OPC UA client behaviour. A dedicated tick is
+ /// the one signal that is true whether or not anyone is watching. Its cost is a parse of
+ /// the device model per interval, which is why
+ /// exists.
+ ///
+ ///
+ /// It waits first, then probes. has just completed a
+ /// successful /probe one moment earlier; a tick that fired immediately would spend
+ /// a second identical request to learn nothing, and would do it on every re-initialize.
+ ///
+ ///
+ /// It never calls a lifecycle method — see the remarks.
+ /// awaits this task while holding that semaphore, so
+ /// reaching back into one would be a permanent hang with no exception and no log line.
+ ///
+ ///
+ private async Task RunProbeLoopAsync(
+ IMTConnectAgentClient client, MTConnectDriverOptions options, CancellationToken ct)
+ {
+ try
+ {
+ while (!ct.IsCancellationRequested)
+ {
+ try
+ {
+ await _probeScheduler(options.Probe.Interval, ct).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ return;
+ }
+
+ if (ct.IsCancellationRequested)
+ {
+ return;
+ }
+
+ bool reachable;
+ try
+ {
+ using var deadline = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ deadline.CancelAfter(options.Probe.Timeout);
+
+ // The answer is deliberately discarded — see the remarks.
+ _ = await client.ProbeAsync(deadline.Token).ConfigureAwait(false);
+ reachable = true;
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ return;
+ }
+ catch (Exception ex)
+ {
+ // Unreachable, unusable, disposed, or past its deadline — from the host's point
+ // of view these are one fact, and the data paths report the detail.
+ reachable = false;
+
+ _logger.LogDebug(
+ ex,
+ "MTConnect driver {DriverInstanceId} could not reach {AgentUri} on its connectivity probe.",
+ _driverInstanceId, options.AgentUri);
+ }
+
+ TransitionHostTo(reachable ? HostState.Running : HostState.Stopped);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(
+ ex,
+ "MTConnect driver {DriverInstanceId} stopped its connectivity probe on an unexpected fault; host status for {AgentUri} will not be updated until the driver is re-initialized.",
+ _driverInstanceId, options.AgentUri);
+ }
+ }
+
+ ///
+ /// Publishes a host state and announces it — only when it actually changed. Raising on
+ /// every tick would emit one event per interval per Agent forever: Core would re-fan Bad
+ /// quality across the host's subtree each time, and an operator watching the feed could not
+ /// tell a new outage from an ongoing one.
+ ///
+ private void TransitionHostTo(HostState newState)
+ {
+ HostState old;
+ lock (_probeLock)
+ {
+ old = _hostState;
+ if (old == newState)
+ {
+ return;
+ }
+
+ _hostState = newState;
+ _hostStateChangedUtc = DateTime.UtcNow;
+ }
+
+ _logger.LogInformation(
+ "MTConnect driver {DriverInstanceId} host {AgentUri} went {Previous} -> {Current}.",
+ _driverInstanceId, HostName, old, newState);
+
+ // Outside the lock: a handler is caller code.
+ try
+ {
+ OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(HostName, old, newState));
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(
+ ex,
+ "MTConnect driver {DriverInstanceId} caught a fault from a host-status subscriber for {AgentUri}; the probe continues.",
+ _driverInstanceId, HostName);
+ }
+ }
+
+ ///
+ /// Stops the connectivity probe loop and waits for it to be gone. Idempotent, and never
+ /// throws — it runs on teardown paths where a second exception would mask the first.
+ ///
+ ///
+ /// ⚠️ This runs while is held and it awaits the loop, on
+ /// exactly the same terms as : safe only because the loop
+ /// never touches the lifecycle and every await it makes observes the token cancelled below.
+ /// The wait is bounded by and the caller's token for the same
+ /// reason: an handler that never returns would otherwise
+ /// wedge the Akka dispatcher thread DriverInstanceActor.PostStop blocks on.
+ ///
+ /// The caller's deadline for the teardown wait.
+ private async Task StopProbeLoopAsync(CancellationToken cancellationToken = default)
+ {
+ var cts = _probeCts;
+ var task = Volatile.Read(ref _probeTask);
+ _probeCts = null;
+ Volatile.Write(ref _probeTask, null);
+
+ if (cts is null)
+ {
+ return;
+ }
+
+ try
+ {
+ await cts.CancelAsync().ConfigureAwait(false);
+ }
+ catch (ObjectDisposedException)
+ {
+ // Raced another stop; the loop is already going away.
+ }
+
+ var stopped = true;
+ if (task is not null)
+ {
+ try
+ {
+ await task.WaitAsync(TeardownWait, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is TimeoutException or OperationCanceledException)
+ {
+ stopped = false;
+
+ _logger.LogError(
+ ex,
+ "MTConnect driver {DriverInstanceId} gave up waiting for its connectivity probe to stop after {TeardownWait}; the usual cause is an OnHostStatusChanged subscriber that blocks. Teardown continues.",
+ _driverInstanceId, TeardownWait);
+ }
+ catch (Exception ex)
+ {
+ // RunProbeLoopAsync is written not to throw, so this is belt-and-braces.
+ _logger.LogDebug(
+ ex,
+ "MTConnect driver {DriverInstanceId} swallowed a fault from its connectivity probe while stopping it.",
+ _driverInstanceId);
+ }
+ }
+
+ if (stopped)
+ {
+ // Only once the loop is provably gone — see StopSampleStreamAsync for why.
+ cts.Dispose();
+ }
+ }
+
+ // ---- IRediscoverable: the Agent instanceId watch ----
+
+ ///
+ ///
+ ///
+ /// This event is what makes =
+ /// safe. The runtime runs exactly one
+ /// discovery pass per connect, so an Agent that restarts mid-run and renumbers, adds, or
+ /// drops data items would otherwise leave a stale address space sitting behind a
+ /// perfectly Healthy driver.
+ ///
+ ///
+ /// Raised from the /sample pump's task, outside every lock. A handler that
+ /// throws is absorbed and logged — a consumer bug must not tear down the stream serving
+ /// every subscriber. A handler that blocks blocks the pump, and one that
+ /// synchronously awaits a lifecycle method on this driver deadlocks it: teardown waits
+ /// for the pump, and the pump is waiting inside the handler. Core's consumer schedules
+ /// the rediscovery rather than running it inline, which is the only safe shape.
+ ///
+ ///
+ public event EventHandler? OnRediscoveryNeeded;
+
+ ///
+ /// Handles an Agent restart observed on the wire: drop the device model it invalidated, then
+ /// tell Core to re-discover — once per distinct instanceId.
+ ///
+ ///
+ ///
+ /// Dropping the cached /probe model is not housekeeping — it is what stops this
+ /// whole capability being inert. Core's response to the event is to re-run
+ /// , which reads
+ /// ; with the cache still warm that call answers
+ /// from a model describing a process that no longer exists, and the "rediscovery" would
+ /// diff the stale tree against itself and change nothing. Dropped through the same
+ /// compare-and-swap uses, so a lifecycle change
+ /// landing mid-drop wins rather than being undone.
+ ///
+ ///
+ /// Once per change, not once per chunk. Every chunk after a restart carries the new
+ /// id, and a re-baseline that fails leaves the pump still comparing against the old one —
+ /// so the guard is a latch on the id that was last announced, not on the pump's cursor
+ /// state. A second, different restart is a second announcement; an id the Agent reuses
+ /// after some other id is one too, because that is genuinely a new process.
+ ///
+ ///
+ /// The instanceId the Agent's chunk reported.
+ /// The options in force, for the log line's endpoint.
+ private void AnnounceAgentRestart(long instanceId, MTConnectDriverOptions options)
+ {
+ if (Interlocked.Exchange(ref _announcedInstanceId, instanceId) == instanceId)
+ {
+ return;
+ }
+
+ InvalidateProbeModel();
+
+ _logger.LogInformation(
+ "MTConnect driver {DriverInstanceId} dropped its cached /probe device model for {AgentUri} and raised rediscovery: agent instanceId is now {InstanceId}.",
+ _driverInstanceId, options.AgentUri, instanceId);
+
+ try
+ {
+ OnRediscoveryNeeded?.Invoke(this, new RediscoveryEventArgs("MTConnect agent instanceId changed", null));
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(
+ ex,
+ "MTConnect driver {DriverInstanceId} caught a fault from a rediscovery subscriber; the /sample stream continues.",
+ _driverInstanceId);
+ }
+ }
+
+ ///
+ /// Drops the cached /probe device model so the next consumer re-fetches it. Same
+ /// compare-and-swap discipline as — retried, because
+ /// unlike a flush this drop is a correctness requirement rather than a courtesy: losing the
+ /// race must not leave a model from a dead Agent installed.
+ ///
+ private void InvalidateProbeModel()
+ {
+ while (true)
+ {
+ var session = Volatile.Read(ref _session);
+ if (session?.ProbeModel is null)
+ {
+ return;
+ }
+
+ var dropped = session with { ProbeModel = null, ProbeModelDataItemCount = 0 };
+ if (ReferenceEquals(Interlocked.CompareExchange(ref _session, dropped, session), session))
+ {
+ return;
+ }
+ }
+ }
+
+ ///
+ /// Starts the shared /sample pump, if there is anything to pump and anything to pump
+ /// from. Caller must hold .
+ ///
+ ///
+ /// Whether the pump should republish the observation index to every subscriber before it
+ /// opens the stream — true when a lifecycle start replaced the baseline the subscribers'
+ /// current values came from.
+ ///
+ private void StartSampleStreamCore(bool republishOnStart)
+ {
+ // `IsCompleted: false`, not `is not null`: a pump that has already exited is not a running
+ // stream, and conflating the two means a pump killed by an unexpected fault could never be
+ // revived by a resubscribe. The genuinely-unrepeatable case is the latch below, not this.
+ if (_pumpTask is { IsCompleted: false })
+ {
+ return;
+ }
+
+ if (_streamUnsupported)
+ {
+ // Refusing IS correct — reconnecting reproduces the identical answer forever — but it
+ // must never be silent. DriverInstanceActor handles a tag-set change as
+ // Unsubscribe-then-Subscribe with NO re-initialize, and the unsubscribe stops a stream
+ // that is not running; without this, the resubscribe returns a handle, the caller is
+ // told SubscriptionEstablished, and the next successful read reports a perfectly green
+ // driver over a subscription that can never deliver a value (#485).
+ _streamDegraded = true;
+ Degrade($"The MTConnect Agent at '{_options.AgentUri}' does not serve a framed /sample stream; subscriptions cannot deliver until the driver is re-initialized.");
+
+ _logger.LogWarning(
+ "MTConnect driver {DriverInstanceId} refused to start a /sample stream for {AgentUri}: the endpoint was already found not to stream. The subscription is registered but will receive NO updates until the driver is re-initialized.",
+ _driverInstanceId, _options.AgentUri);
+
+ return;
+ }
+
+ if (!HasSubscribedReferencesCore())
+ {
+ return;
+ }
+
+ // One read of the session: the client the pump enumerates and the cursor it opens at are
+ // guaranteed to belong to the same initialize.
+ var session = Volatile.Read(ref _session);
+ if (session is null)
+ {
+ return;
+ }
+
+ // A previous pump ran to completion; its cancellation source is ours to release before the
+ // field is overwritten, or every revived stream leaks one.
+ _pumpCts?.Dispose();
+ _pumpTask = null;
+
+ // A new stream generation gets a fresh subscriber-fault Warning budget (see
+ // _subscriberFaultLogged).
+ Interlocked.Exchange(ref _subscriberFaultLogged, 0);
+
+ var options = _options;
+ var cts = new CancellationTokenSource();
+ _pumpCts = cts;
+ _pumpTask = Task.Run(
+ () => RunSampleStreamAsync(
+ session.Client, options, session.NextSequence, session.InstanceId, republishOnStart, cts.Token),
+ CancellationToken.None);
+ }
+
+ ///
+ /// The shared /sample pump: reads chunks, keeps the observation index and the
+ /// subscribers up to date, and owns every way the stream can stop.
+ ///
+ ///
+ ///
+ /// Never throws. It is a detached background task; an escaping exception would be
+ /// an unobserved fault that silently ends the driver's subscription surface while every
+ /// handle still looks alive.
+ ///
+ ///
+ /// Never calls a lifecycle method. Its re-baseline goes straight to
+ /// on the client it already holds — see
+ /// and the remarks for why
+ /// "just re-initialize" would be a silent permanent deadlock.
+ ///
+ ///
+ /// Backoff applies to failures only. A re-baseline is a protocol event, not a
+ /// fault, so it reopens the stream immediately and resets the attempt count; only a
+ /// genuine failure (dropped connection, blown heartbeat, a re-baseline that itself
+ /// failed) advances .
+ ///
+ ///
+ private async Task RunSampleStreamAsync(
+ IMTConnectAgentClient client,
+ MTConnectDriverOptions options,
+ long from,
+ long instanceId,
+ bool republishOnStart,
+ CancellationToken ct)
+ {
+ var cursor = from;
+ var attempt = 0;
+ _reconnectAttempts = 0;
+
+ try
+ {
+ if (republishOnStart)
+ {
+ FanOut(SubscribedReferences(), ObservationIndex);
+ }
+
+ while (!ct.IsCancellationRequested)
+ {
+ if (attempt > 0)
+ {
+ var delay = BackoffFor(attempt, options.Reconnect);
+ if (delay > TimeSpan.Zero)
+ {
+ try
+ {
+ await Task.Delay(delay, ct).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ return;
+ }
+ }
+ }
+
+ var outcome = await ConsumeStreamAsync(client, options, cursor, instanceId, ct).ConfigureAwait(false);
+ cursor = outcome.Cursor;
+ instanceId = outcome.InstanceId;
+
+ switch (outcome.Verdict)
+ {
+ case StreamVerdict.Cancelled:
+ return;
+
+ case StreamVerdict.Unsupported:
+ // Configuration, not weather: reconnecting reproduces the identical answer
+ // forever, so retrying would be an unbounded hot loop against an endpoint
+ // that will never stream until an operator changes something. Latch it, say
+ // so loudly, and stop. A re-initialize clears the latch.
+ lock (_subscriptionLock)
+ {
+ _streamUnsupported = true;
+ }
+
+ _streamDegraded = true;
+ Degrade(outcome.Error!);
+
+ _logger.LogError(
+ outcome.Error,
+ "MTConnect driver {DriverInstanceId} cannot stream /sample from {AgentUri}: the endpoint did not answer with a framed multipart stream. Subscriptions stay registered but will receive no updates until the driver is re-initialized; check for a proxy or load balancer in front of the Agent.",
+ _driverInstanceId, options.AgentUri);
+
+ return;
+
+ case StreamVerdict.Reopen:
+ attempt = 0;
+ _reconnectAttempts = 0;
+
+ break;
+
+ default:
+ _streamDegraded = true;
+ Degrade(outcome.Error!);
+
+ // A stream that DELIVERED before it dropped proves the endpoint works, so
+ // its failure starts a fresh backoff ladder. Without this the counter is
+ // monotonic for the life of the process: a healthy agent that drops a
+ // connection once an hour would be pinned at MaxBackoffMs within a day, and
+ // "the first retry is immediate" would be true exactly once ever.
+ attempt = outcome.DeliveredAny ? 1 : attempt + 1;
+ _reconnectAttempts = attempt;
+
+ _logger.LogWarning(
+ outcome.Error,
+ "MTConnect driver {DriverInstanceId} lost the /sample stream from {AgentUri}; reconnecting from sequence {Cursor} (attempt {Attempt}, delivered={Delivered}).",
+ _driverInstanceId, options.AgentUri, cursor, attempt, outcome.DeliveredAny);
+
+ break;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(
+ ex,
+ "MTConnect driver {DriverInstanceId} stopped its /sample pump on an unexpected fault; subscriptions will receive no further updates until the driver is re-initialized.",
+ _driverInstanceId);
+
+ _streamDegraded = true;
+ Degrade(ex);
+ }
+ finally
+ {
+ // The session's cursor is the opening `from` of the NEXT pump, and until now it only
+ // ever held the priming /current's. A last-unsubscribe + resubscribe (no re-initialize)
+ // would therefore reopen at a sequence the Agent has long since evicted: it self-heals
+ // via OUT_OF_RANGE, but only after a wasted round trip, and a still-buffered stale
+ // cursor replays historical observations to subscribers first. One publish per pump
+ // lifetime, dropped if a lifecycle change has already installed a newer session.
+ PublishAgentCursor(instanceId, cursor);
+ }
+ }
+
+ ///
+ /// Enumerates one /sample connection until it ends, and classifies how it ended.
+ /// Re-baselines are performed here, inside the enumeration, so the index and the
+ /// subscribers are up to date before the stream is reopened.
+ ///
+ private async Task ConsumeStreamAsync(
+ IMTConnectAgentClient client,
+ MTConnectDriverOptions options,
+ long cursor,
+ long instanceId,
+ CancellationToken ct)
+ {
+ // The RUNNING cursor the gap check is made against: the previous chunk's nextSequence, equal
+ // to the opening `from` only for the first chunk. Comparing every chunk against the opening
+ // `from` instead reports a gap on every chunk once the Agent's buffer rolls past it — an
+ // endless /current re-baseline storm against a perfectly healthy stream.
+ var expected = cursor;
+
+ // Whether THIS connection ever handed us a chunk. A stream that delivered and then dropped
+ // is a working endpoint having a bad moment; one that never delivered may be an endpoint
+ // that cannot work at all. They must not share a backoff ladder — see RunSampleStreamAsync.
+ var delivered = false;
+
+ try
+ {
+ await foreach (var chunk in client.SampleAsync(cursor, ct).ConfigureAwait(false))
+ {
+ if (chunk is null)
+ {
+ continue;
+ }
+
+ // CHECKED BEFORE THE GAP: an Agent restart changes instanceId *and* resets
+ // sequences, so a restart usually trips IsSequenceGap too — and the two need
+ // different handling. A gap keeps the held values (they are still this device's);
+ // a restart invalidates every one of them, because the device model they describe
+ // no longer exists. Testing the gap first would misdiagnose the restart and keep
+ // serving values the new Agent may never report again.
+ if (chunk.InstanceId != instanceId)
+ {
+ _logger.LogWarning(
+ "MTConnect driver {DriverInstanceId} saw {AgentUri} restart (instanceId {Previous} -> {Current}); dropping every held observation and re-baselining.",
+ _driverInstanceId, options.AgentUri, instanceId, chunk.InstanceId);
+
+ // Dropped BEFORE the re-baseline, and the subscribers are told: if the /current
+ // then fails, holding stale values from a dead device model would be worse than
+ // holding none.
+ ObservationIndex.Clear();
+ FanOut(SubscribedReferences(), ObservationIndex);
+
+ // The driver's own state is consistent by this point, so it is safe to hand the
+ // restart to arbitrary caller code — which may call straight back into
+ // DiscoverAsync.
+ AnnounceAgentRestart(chunk.InstanceId, options);
+
+ return await RebaselineAsync(client, options, expected, instanceId, delivered, ct)
+ .ConfigureAwait(false);
+ }
+
+ if (IMTConnectAgentClient.IsSequenceGap(expected, chunk))
+ {
+ _logger.LogWarning(
+ "MTConnect driver {DriverInstanceId} fell out of {AgentUri}'s buffer (expected sequence {Expected}, oldest retained {FirstSequence}); re-baselining from /current.",
+ _driverInstanceId, options.AgentUri, expected, chunk.FirstSequence);
+
+ return await RebaselineAsync(client, options, expected, instanceId, delivered, ct)
+ .ConfigureAwait(false);
+ }
+
+ ApplyAndFanOut(chunk);
+ delivered = true;
+
+ // EVERY chunk advances the cursor, including an observation-free heartbeat: the
+ // Agent sends those precisely so a quiet connection can be told from a dead one, and
+ // they carry the sequence forward. Not advancing on one makes the NEXT chunk look
+ // like a gap.
+ expected = chunk.NextSequence;
+
+ _streamDegraded = false;
+ PublishHealthy(DateTime.UtcNow);
+ }
+
+ // The seam contracts that cancellation is the ONLY way this enumeration ends quietly, so
+ // a quiet end under a live token is a non-conforming client — reported as the lost
+ // stream it is rather than treated as "the subscription is simply idle" (#485).
+ return ct.IsCancellationRequested
+ ? new StreamOutcome(StreamVerdict.Cancelled, expected, instanceId, delivered, null)
+ : new StreamOutcome(
+ StreamVerdict.Transient,
+ expected,
+ instanceId,
+ delivered,
+ new MTConnectStreamEndedException(
+ MTConnectStreamEndReason.ConnectionClosed, options.AgentUri, 0));
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ return new StreamOutcome(StreamVerdict.Cancelled, expected, instanceId, delivered, null);
+ }
+ catch (MTConnectStreamNotSupportedException ex)
+ {
+ return new StreamOutcome(StreamVerdict.Unsupported, expected, instanceId, delivered, ex);
+ }
+ catch (InvalidDataException ex)
+ {
+ // THE OTHER HALF OF RING-BUFFER OVERFLOW, and the one IsSequenceGap cannot see: a real
+ // cppagent answers a `from` that has already fallen out of its buffer with an
+ // MTConnectError / OUT_OF_RANGE document served under HTTP 200, which the client
+ // surfaces here rather than as a gap-bearing chunk. Re-baselining on the gap alone would
+ // mean the driver recovers from falling a little behind but not from falling a lot.
+ _logger.LogWarning(
+ ex,
+ "MTConnect driver {DriverInstanceId} could not resume {AgentUri}'s /sample stream at sequence {Cursor} (the Agent rejected it as out of range); re-baselining from /current.",
+ _driverInstanceId, options.AgentUri, cursor);
+
+ return await RebaselineAsync(client, options, expected, instanceId, delivered, ct)
+ .ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ // Transient by default: a dropped connection, a closing boundary, the heartbeat
+ // watchdog's TimeoutException. Reconnect under backoff.
+ return new StreamOutcome(StreamVerdict.Transient, expected, instanceId, delivered, ex);
+ }
+ }
+
+ ///
+ /// Re-primes the observation index from a fresh /current and reports where the stream
+ /// should resume. The recovery path for every way the incremental sequence can be broken:
+ /// a ring-buffer overflow (detected as a gap, or refused outright as OUT_OF_RANGE)
+ /// and an Agent restart.
+ ///
+ ///
+ /// This calls the client directly and takes no lock. Re-baselining by calling
+ /// — the obvious way to write it, since that method already
+ /// does exactly this work — would take the non-reentrant semaphore
+ /// from inside a pump that a lifecycle method may already be waiting on, and hang the driver
+ /// permanently with no exception and no log line.
+ ///
+ private async Task RebaselineAsync(
+ IMTConnectAgentClient client,
+ MTConnectDriverOptions options,
+ long fallbackCursor,
+ long fallbackInstanceId,
+ bool deliveredAny,
+ CancellationToken ct)
+ {
+ try
+ {
+ var current = await BoundedAsync(client.CurrentAsync, "/current", options.RequestTimeoutMs, ct)
+ .ConfigureAwait(false);
+
+ PublishAgentCursor(current.InstanceId, current.NextSequence);
+ ApplyAndFanOut(current);
+
+ _streamDegraded = false;
+ PublishHealthy(DateTime.UtcNow);
+
+ _logger.LogInformation(
+ "MTConnect driver {DriverInstanceId} re-baselined from {AgentUri}'s /current ({ObservationCount} observation(s), agent instanceId {InstanceId}) and resumes /sample from sequence {NextSequence}.",
+ _driverInstanceId,
+ options.AgentUri,
+ current.Observations.Count,
+ current.InstanceId,
+ current.NextSequence);
+
+ return new StreamOutcome(
+ StreamVerdict.Reopen, current.NextSequence, current.InstanceId, deliveredAny, null);
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ return new StreamOutcome(
+ StreamVerdict.Cancelled, fallbackCursor, fallbackInstanceId, deliveredAny, null);
+ }
+ catch (Exception ex)
+ {
+ // The Agent is unreachable or unusable right now. Treated as an ordinary transient
+ // failure so the reconnect backoff applies — without it, an Agent that is down would be
+ // re-baselined against as fast as the loop can spin. The cursor is deliberately left
+ // where it was: reopening there either works or lands right back here.
+ return new StreamOutcome(
+ StreamVerdict.Transient, fallbackCursor, fallbackInstanceId, deliveredAny, ex);
+ }
+ }
+
+ ///
+ /// The reconnect delay before attempt , as a pure function — the
+ /// backoff policy is asserted directly rather than inferred from how long a test slept.
+ ///
+ ///
+ /// The first retry honours (0 by
+ /// default = immediate, which is what an operator wants after a one-off blip); every later
+ /// one multiplies, from a floor, up to
+ /// . Operator-authored nonsense cannot
+ /// un-bound the loop: a multiplier that cannot grow the delay falls back to
+ /// , a non-positive cap falls back to
+ /// (clamping to it would make EVERY delay zero — a spin,
+ /// not a backoff), and a negative minimum clamps to zero.
+ ///
+ /// The 1-based reconnect attempt about to be made.
+ /// The authored backoff options.
+ /// How long to wait before that attempt.
+ internal static TimeSpan BackoffFor(int attempt, MTConnectReconnectOptions reconnect)
+ {
+ ArgumentNullException.ThrowIfNull(reconnect);
+
+ // A non-positive cap is "unset", NOT "no cap". Math.Max(0, ...) would clamp every delay to
+ // zero and turn the reconnect loop into a spin against a refused connection.
+ var capMs = reconnect.MaxBackoffMs > 0 ? reconnect.MaxBackoffMs : DefaultMaxBackoffMs;
+ var minMs = Math.Max(0, reconnect.MinBackoffMs);
+
+ if (attempt <= 1)
+ {
+ return TimeSpan.FromMilliseconds(Math.Min(minMs, capMs));
+ }
+
+ var multiplier = reconnect.BackoffMultiplier > 1.0 ? reconnect.BackoffMultiplier : DefaultBackoffMultiplier;
+ var delayMs = (double)Math.Max(minMs, BackoffGrowthFloorMs);
+
+ for (var step = 2; step <= attempt; step++)
+ {
+ delayMs *= multiplier;
+
+ if (delayMs >= capMs)
+ {
+ return TimeSpan.FromMilliseconds(capMs);
+ }
+ }
+
+ return TimeSpan.FromMilliseconds(Math.Min(delayMs, capMs));
+ }
+
+ ///
+ /// Folds a /current snapshot or one /sample chunk into the shared index and
+ /// publishes every reference it touched to the handles that subscribe it.
+ ///
+ private void ApplyAndFanOut(MTConnectStreamsResult document)
+ {
+ var index = ObservationIndex;
+ index.Apply(document);
+
+ if (document.Observations is not { Count: > 0 })
+ {
+ return;
+ }
+
+ // De-duplicated in document order: one Agent document may report a data item more than once
+ // (a Condition container carries several simultaneously-active states), and the index has
+ // already reconciled those into a single snapshot.
+ //
+ // The Agent reports DataItem ids; subscribers hold RawPaths. Each touched id is therefore
+ // EXPANDED into the references it is published under — every RawPath bound to it (a DataItem
+ // may legitimately back more than one authored raw tag), plus the id itself for the
+ // dataItemId-keyed authoring surface (the driver CLI). Publishing the id instead of the
+ // RawPath would miss DriverHostActor's NodeId table and drop the value silently.
+ var binding = Volatile.Read(ref _binding);
+ var touched = new List(document.Observations.Count);
+ var seen = new HashSet(StringComparer.Ordinal);
+ foreach (var observation in document.Observations)
+ {
+ if (observation is null || string.IsNullOrWhiteSpace(observation.DataItemId))
+ {
+ continue;
+ }
+
+ if (seen.Add(observation.DataItemId))
+ {
+ binding.AppendReferences(observation.DataItemId, touched);
+ }
+ }
+
+ FanOut(touched, index);
+ }
+
+ ///
+ /// Publishes the index's current snapshot of to every handle
+ /// that subscribes them. References nobody subscribed raise nothing: the Agent streams the
+ /// whole device, and republishing all of it would flood the server with values no client
+ /// asked for.
+ ///
+ ///
+ /// are subscriber references — RawPaths under v3 — and
+ /// each is carried through to the callback unchanged; only the index lookup behind it is
+ /// translated to the DataItem id the Agent reports under. That asymmetry is the point: the
+ /// value must arrive under the reference the caller subscribed, or it is routed nowhere.
+ ///
+ private void FanOut(IReadOnlyList references, MTConnectObservationIndex index)
+ {
+ if (references.Count == 0)
+ {
+ return;
+ }
+
+ SubscriptionState[] states;
+ lock (_subscriptionLock)
+ {
+ if (_subscriptions.Count == 0)
+ {
+ return;
+ }
+
+ states = [.. _subscriptions.Values];
+ }
+
+ var binding = Volatile.Read(ref _binding);
+
+ // The lock is released before any callback: handlers are caller code.
+ foreach (var reference in references)
+ {
+ DataValueSnapshot? snapshot = null;
+
+ foreach (var state in states)
+ {
+ if (!state.References.Contains(reference))
+ {
+ continue;
+ }
+
+ // Read once per reference, so every handle sees the identical snapshot.
+ snapshot ??= index.Get(binding.ToDataItemId(reference));
+ RaiseDataChange(state.Handle, reference, snapshot);
+ }
+ }
+ }
+
+ ///
+ /// Raises one data-change callback to every subscriber, absorbing anything each one
+ /// throws. A consumer bug must not tear down the pump that serves the others — nor rob them
+ /// of the value.
+ ///
+ ///
+ ///
+ /// The invocation list is walked by hand, with the try/catch INSIDE the loop. A
+ /// plain OnDataChange?.Invoke(...) is a multicast call: the first handler that
+ /// throws aborts the rest of the list, so one broken consumer silently starves every
+ /// other subscriber of that observation — and a single try/catch around the whole
+ /// invoke absorbs the exception while leaving the starvation in place, which reads as
+ /// "isolated" in a log and is not.
+ ///
+ ///
+ /// One is shared across the list deliberately: it is
+ /// an immutable record, and allocating per handler on the hot path would buy nothing.
+ ///
+ ///
+ private void RaiseDataChange(MTConnectSampleHandle handle, string reference, DataValueSnapshot snapshot)
+ {
+ var handlers = OnDataChange;
+ if (handlers is null)
+ {
+ return;
+ }
+
+ var args = new DataChangeEventArgs(handle, reference, snapshot);
+
+ foreach (var registration in handlers.GetInvocationList())
+ {
+ try
+ {
+ ((EventHandler)registration).Invoke(this, args);
+ }
+ catch (Exception ex)
+ {
+ LogSubscriberFault(ex, reference, handle);
+ }
+ }
+ }
+
+ ///
+ /// Reports a faulting data-change subscriber once per stream generation at Warning,
+ /// and at Debug thereafter.
+ ///
+ ///
+ /// A consumer that throws on every value would otherwise emit one Warning per observation
+ /// per handle — on a busy Agent, thousands a second, which buries the incident it is
+ /// reporting along with everything else in the log. Nothing is lost: the later faults are
+ /// still emitted, at Debug, and the latch is cleared whenever the pump (re)starts.
+ ///
+ private void LogSubscriberFault(Exception ex, string reference, MTConnectSampleHandle handle)
+ {
+ if (Interlocked.Exchange(ref _subscriberFaultLogged, 1) == 0)
+ {
+ _logger.LogWarning(
+ ex,
+ "MTConnect driver {DriverInstanceId} caught a fault from a data-change subscriber for {Reference} on {SubscriptionId}; the stream continues and every other subscriber still received the value. Further subscriber faults are logged at Debug until the stream restarts.",
+ _driverInstanceId, reference, handle.DiagnosticId);
+
+ return;
+ }
+
+ _logger.LogDebug(
+ ex,
+ "MTConnect driver {DriverInstanceId} caught a further fault from a data-change subscriber for {Reference} on {SubscriptionId}.",
+ _driverInstanceId, reference, handle.DiagnosticId);
+ }
+
+ /// Every reference any live subscription is interested in.
+ private List SubscribedReferences()
+ {
+ lock (_subscriptionLock)
+ {
+ var all = new HashSet(StringComparer.Ordinal);
+ foreach (var state in _subscriptions.Values)
+ {
+ all.UnionWith(state.References);
+ }
+
+ return [.. all];
+ }
+ }
+
+ ///
+ /// Whether any live subscription actually names a reference. Caller must hold
+ /// . A subscription over an empty reference list is legal
+ /// (a caller that adds tags later) and must not open a stream by itself.
+ ///
+ private bool HasSubscribedReferencesCore()
+ {
+ foreach (var state in _subscriptions.Values)
+ {
+ if (state.References.Count > 0)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Records where the Agent now is — its instanceIdand the sequence a stream
+ /// should resume from — onto whichever session is installed.
+ ///
+ ///
+ ///
+ /// Both fields move together, in one compare-and-swap. They are one fact about
+ /// one Agent process, and the session's own contract is that a reader gets the client
+ /// and its cursor as a consistent pair. Publishing the id alone (as this did) left the
+ /// session holding a NEW instanceId beside the cursor of the process that had just
+ /// died — the exact tear the packing exists to prevent.
+ ///
+ ///
+ /// There is no "the id did not change, so skip" early return, because the two
+ /// re-baselines that do NOT change the id — a sequence gap and an OUT_OF_RANGE
+ /// rejection — are precisely the ones whose whole purpose is to move the cursor.
+ /// Suppressing the write there would leave the next pump reopening at the stale
+ /// sequence that had just been rejected.
+ ///
+ ///
+ /// Compare-and-swap for the same reason uses one:
+ /// a lifecycle change landing mid-update has newer state, so this write is dropped
+ /// rather than replayed over it.
+ ///
+ ///
+ /// The Agent instanceId last observed.
+ /// The sequence a /sample stream should resume from.
+ private void PublishAgentCursor(long instanceId, long nextSequence)
+ {
+ while (true)
+ {
+ var session = Volatile.Read(ref _session);
+ if (session is null ||
+ (session.InstanceId == instanceId && session.NextSequence == nextSequence))
+ {
+ return;
+ }
+
+ var updated = session with { InstanceId = instanceId, NextSequence = nextSequence };
+ if (ReferenceEquals(Interlocked.CompareExchange(ref _session, updated, session), session))
+ {
+ return;
+ }
+ }
+ }
+
+ ///
+ /// The /probe device model: the cached copy when warm, otherwise a fresh
+ /// deadline-bounded /probe that re-populates the cache. This is the accessor every
+ /// model consumer (Task 12's DiscoverAsync) must use, so that
+ /// can never leave browse permanently disabled.
+ ///
+ ///
+ /// Lock-free by design — see the remarks for why this does not take
+ /// . It reads the session once, fetches against that
+ /// session's client, and re-caches only if that same session is still installed, so a
+ /// concurrent / can neither be
+ /// undone by a late re-cache nor leak a model belonging to a client that is gone. A teardown
+ /// that lands mid-fetch surfaces as the below rather
+ /// than as a raw from the disposed client.
+ ///
+ /// Cancellation token.
+ /// The Agent's device model.
+ ///
+ /// The driver is not initialized, or it was torn down while this fetch was in flight.
+ ///
+ internal async Task GetOrFetchProbeModelAsync(CancellationToken cancellationToken)
+ {
+ var session = Volatile.Read(ref _session) ?? throw NotConnected();
+
+ if (session.ProbeModel is not null)
+ {
+ return session.ProbeModel;
+ }
+
+ MTConnectProbeModel model;
+ try
+ {
+ model = await BoundedAsync(session.Client.ProbeAsync, "/probe", _options.RequestTimeoutMs, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ catch (ObjectDisposedException ex)
+ {
+ // The lock-free window the _session remarks name: a teardown disposed this client after
+ // we captured it. Reported as the not-connected error the caller already handles, so
+ // browse has exactly one failure mode to reason about.
+ throw NotConnected(ex);
+ }
+
+ // Publish only onto the session we actually fetched against.
+ var recached = session with { ProbeModel = model, ProbeModelDataItemCount = CountDataItems(model) };
+ _ = Interlocked.CompareExchange(ref _session, recached, session);
+
+ return model;
+ }
+
+ private InvalidOperationException NotConnected(Exception? inner = null) =>
+ new($"MTConnect driver '{_driverInstanceId}' has no live agent client; the /probe device model cannot be fetched unless InitializeAsync has succeeded and the driver has not since been shut down or re-initialized.",
+ inner);
+
+ ///
+ /// Builds driver options from a DriverConfig JSON document.
+ ///
+ ///
+ /// Lives here rather than on the factory because the driver itself needs it: the runtime
+ /// delivers config changes as JSON to a live instance (see the class remarks). Task 15's
+ /// MTConnectDriverFactoryExtensions.CreateInstance should delegate to this method
+ /// rather than deserializing a second DTO — two parsers for one document drift.
+ ///
+ /// Driver instance id, used only in error text.
+ /// The driver's DriverConfig JSON.
+ /// The parsed options.
+ /// The document is unusable or omits agentUri.
+ internal static MTConnectDriverOptions ParseOptions(string driverInstanceId, string driverConfigJson)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
+ ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
+
+ MTConnectDriverConfigDto? dto;
+ try
+ {
+ dto = JsonSerializer.Deserialize(driverConfigJson, JsonOptions);
+ }
+ catch (JsonException ex)
+ {
+ throw new InvalidOperationException(
+ $"MTConnect driver config for '{driverInstanceId}' is not valid JSON: {ex.Message}", ex);
+ }
+
+ if (dto is null)
+ {
+ throw new InvalidOperationException(
+ $"MTConnect driver config for '{driverInstanceId}' deserialised to null");
+ }
+
+ if (string.IsNullOrWhiteSpace(dto.AgentUri))
+ {
+ throw new InvalidOperationException(
+ $"MTConnect driver config for '{driverInstanceId}' missing required AgentUri");
+ }
+
+ return new MTConnectDriverOptions
+ {
+ AgentUri = dto.AgentUri.Trim(),
+ DeviceName = string.IsNullOrWhiteSpace(dto.DeviceName) ? null : dto.DeviceName.Trim(),
+ RequestTimeoutMs = dto.RequestTimeoutMs ?? 5_000,
+ SampleIntervalMs = dto.SampleIntervalMs ?? 1_000,
+ SampleCount = dto.SampleCount ?? 1_000,
+ HeartbeatMs = dto.HeartbeatMs ?? 10_000,
+ Tags = dto.Tags is { Count: > 0 }
+ ? [.. dto.Tags.Select(t => BuildTag(t, driverInstanceId))]
+ : [],
+
+ // v3: the deploy artifact injects the driver's authored raw tags here (see
+ // DriverDeviceConfigMerger). Bound verbatim — the per-entry TagConfig blob is mapped
+ // later, at binding-build time, where a bad blob can be skipped rather than failing the
+ // whole deployment over one tag.
+ RawTags = dto.RawTags is { Count: > 0 } ? [.. dto.RawTags] : [],
+ Probe = new MTConnectProbeOptions
+ {
+ Enabled = dto.Probe?.Enabled ?? true,
+ Interval = TimeSpan.FromMilliseconds(dto.Probe?.IntervalMs ?? 5_000),
+ Timeout = TimeSpan.FromMilliseconds(dto.Probe?.TimeoutMs ?? 2_000),
+ },
+ Reconnect = new MTConnectReconnectOptions
+ {
+ MinBackoffMs = dto.Reconnect?.MinBackoffMs ?? 0,
+ MaxBackoffMs = dto.Reconnect?.MaxBackoffMs ?? 30_000,
+ BackoffMultiplier = dto.Reconnect?.BackoffMultiplier ?? 2.0,
+ },
+ };
+ }
+
+ // ---- lifecycle internals (all called with _lifecycle held) ----
+
+ ///
+ /// Brings the driver up against , reusing
+ /// when the caller determined the transport is unchanged.
+ /// Nothing is published until BOTH agent calls have succeeded, so a failure can never leave a
+ /// half-initialized instance behind.
+ ///
+ private async Task StartCoreAsync(
+ MTConnectDriverOptions options, IMTConnectAgentClient? existingClient, CancellationToken ct)
+ {
+ var lastRead = ReadHealth().LastSuccessfulRead;
+ WriteHealth(new DriverHealth(DriverState.Initializing, lastRead, null));
+
+ IMTConnectAgentClient? built = null;
+ try
+ {
+ // arch-review 01/S-6: a 0 authored here must never come to mean "wait forever". Checked
+ // in the driver, not only in the production client's constructor, so the guard holds for
+ // any client implementation behind the seam.
+ RequirePositive(options.RequestTimeoutMs, nameof(MTConnectDriverOptions.RequestTimeoutMs));
+ RequirePositive(options.HeartbeatMs, nameof(MTConnectDriverOptions.HeartbeatMs));
+ RequirePositive(options.SampleIntervalMs, nameof(MTConnectDriverOptions.SampleIntervalMs));
+ RequirePositive(options.SampleCount, nameof(MTConnectDriverOptions.SampleCount));
+
+ // Same 01/S-6 guard for Task 13's two knobs, gated on the feature that reads them. A 0
+ // interval turns the "periodic" probe into an unthrottled hot loop against the Agent, and
+ // a 0 (or negative) timeout cancels every probe before it can be answered — pinning the
+ // host to Stopped forever and reporting an outage that does not exist. Checked only when
+ // probing is ON because a disabled probe reads neither value, and faulting a deployment
+ // over a field the driver never touches would buy nothing; flipping Enabled back on is a
+ // config edit, and ReinitializeAsync runs this same validation.
+ if (options.Probe.Enabled)
+ {
+ RequirePositive(options.Probe.Interval, $"{nameof(MTConnectDriverOptions.Probe)}.{nameof(MTConnectProbeOptions.Interval)}");
+ RequirePositive(options.Probe.Timeout, $"{nameof(MTConnectDriverOptions.Probe)}.{nameof(MTConnectProbeOptions.Timeout)}");
+ }
+
+ var client = existingClient;
+ if (client is null)
+ {
+ built = _agentClientFactory(options)
+ ?? throw new InvalidOperationException(
+ $"The MTConnect agent-client factory for driver '{_driverInstanceId}' returned null.");
+ client = built;
+ }
+
+ var probe = await BoundedAsync(client.ProbeAsync, "/probe", options.RequestTimeoutMs, ct)
+ .ConfigureAwait(false);
+ var current = await BoundedAsync(client.CurrentAsync, "/current", options.RequestTimeoutMs, ct)
+ .ConfigureAwait(false);
+
+ // ---- commit point: everything below is non-throwing bookkeeping ----
+ _options = options;
+
+ // Derived from the options AND the /probe model just fetched: a raw tag whose TagConfig
+ // blob declares no type takes the type the Agent itself declares for that DataItem, so a
+ // browse-committed tag (whose blob carries only the address) publishes as the number it
+ // is rather than as text. Published before the index, which is built from it.
+ var binding = BuildBinding(options, probe);
+ Volatile.Write(ref _binding, binding);
+ built = null;
+
+ // The Agent this session talks to, as of now. A restart is measured against THIS, so a
+ // re-initialize against a different Agent process never re-announces the change the
+ // operator's own action caused.
+ Volatile.Write(ref _announcedInstanceId, current.InstanceId);
+
+ // One publish, so a concurrent lock-free reader sees the new client, the new probe
+ // cache, and the pump's opening cursor together or none of them.
+ Volatile.Write(
+ ref _session,
+ new AgentSession(client, probe, CountDataItems(probe), current.InstanceId, current.NextSequence));
+
+ var index = new MTConnectObservationIndex(binding.IndexTags);
+ index.Apply(current);
+ Volatile.Write(ref _index, index);
+
+ // A fresh session has no failures behind it — including the /sample verdict, because a
+ // re-initialize is precisely the event that means an operator changed something.
+ _streamDegraded = false;
+ _readDegraded = false;
+
+ WriteHealth(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null));
+
+ // Standing subscriptions survive a lifecycle restart; everything they hold came from the
+ // baseline just thrown away, so the restarted pump republishes as its first act. It is
+ // the PUMP that republishes rather than this method, deliberately: raising subscriber
+ // callbacks from here would run caller code on the thread holding the non-reentrant
+ // lifecycle semaphore, and a handler that touched the driver's lifecycle would deadlock.
+ lock (_subscriptionLock)
+ {
+ _streamUnsupported = false;
+ StartSampleStreamCore(republishOnStart: true);
+ }
+
+ StartProbeLoopCore(client, options);
+
+ _logger.LogInformation(
+ "MTConnect driver {DriverInstanceId} initialized against {AgentUri}{DeviceScope}: {DeviceCount} device(s), {ObservationCount} primed observation(s), agent instanceId {InstanceId}.",
+ _driverInstanceId,
+ options.AgentUri,
+ options.DeviceName is null ? string.Empty : $"/{options.DeviceName}",
+ probe.Devices.Count,
+ index.Count,
+ current.InstanceId);
+ }
+ catch (Exception ex)
+ {
+ // A client this attempt built is unreachable from anywhere else; one the caller handed in
+ // is disposed by the teardown below. Either way nothing survives a failed start.
+ SafeDispose(built);
+ await TeardownCoreAsync().ConfigureAwait(false);
+
+ WriteHealth(new DriverHealth(DriverState.Faulted, lastRead, ex.Message));
+
+ _logger.LogError(
+ ex,
+ "MTConnect driver {DriverInstanceId} failed to initialize against {AgentUri}; the driver is Faulted and serves no values.",
+ _driverInstanceId, options.AgentUri);
+
+ throw;
+ }
+ }
+
+ ///
+ /// Releases the agent client and drops every piece of served state. Never throws — it runs
+ /// on the failure path of , where a second exception would mask
+ /// the real one.
+ ///
+ private Task TeardownCoreAsync()
+ {
+ // Retire the whole session in one write, BEFORE disposing: a lock-free reader that still
+ // holds the old reference gets a disposed client (caught + named at each seam), never a
+ // session that is half-torn-down.
+ var session = Volatile.Read(ref _session);
+ Volatile.Write(ref _session, null);
+ SafeDispose(session?.Client);
+
+ // A fresh index rather than Clear(): the tag table it coerces against belongs to the options
+ // that are being torn down. The binding is left in place — it is a pure function of the
+ // options still installed, and a reference must keep resolving to the tag it names so a read
+ // against a torn-down driver answers BadWaitingForInitialData ("authored, no value") rather
+ // than BadNodeIdUnknown ("no such tag").
+ Volatile.Write(ref _index, new MTConnectObservationIndex(Volatile.Read(ref _binding).IndexTags));
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// Stops the shared /sample long-poll stream and waits for the pump to actually be
+ /// gone. Idempotent, and never throws — it runs on teardown paths where a second exception
+ /// would mask the first.
+ ///
+ ///
+ ///
+ /// It waits, rather than merely signalling. Every caller is about to dispose the
+ /// client the pump is enumerating (or install a new observation index the old pump would
+ /// write into). Returning while the pump is still unwinding would let it publish one more
+ /// value into state its owner has already retired — the exact "torn down but still
+ /// reporting" shape the lifecycle is written to prevent.
+ ///
+ ///
+ /// ⚠️ This runs while is held (from
+ /// , and
+ /// ) — and it awaits the pump. That is only safe because the
+ /// pump never touches : its re-baseline calls
+ /// directly (see
+ /// ) rather than re-entering a lifecycle method, and every
+ /// await it makes observes the pump token cancelled below. Break either property and
+ /// this await is a permanent hang with no exception and no log line.
+ ///
+ ///
+ /// The wait is bounded (, and the caller's token) — the
+ /// one thing outside the guarantee above is a subscriber callback, and an
+ /// handler that never returns would otherwise block teardown
+ /// forever. That is not merely slow: DriverInstanceActor.PostStop blocks an Akka
+ /// dispatcher thread on while this driver holds
+ /// , so an unbounded wait wedges an actor-system thread and
+ /// every later lifecycle call with it. Abandoning the wait is safe because the pump's
+ /// registry slots are cleared BEFORE it: a pump left running cannot be resurrected into
+ /// , and it is already cancelled.
+ ///
+ ///
+ /// The caller's deadline for the teardown wait.
+ private async Task StopSampleStreamAsync(CancellationToken cancellationToken = default)
+ {
+ CancellationTokenSource? cts;
+ Task? task;
+
+ lock (_subscriptionLock)
+ {
+ cts = _pumpCts;
+ task = _pumpTask;
+ _pumpCts = null;
+ _pumpTask = null;
+ }
+
+ if (cts is null)
+ {
+ return;
+ }
+
+ try
+ {
+ await cts.CancelAsync().ConfigureAwait(false);
+ }
+ catch (ObjectDisposedException)
+ {
+ // Raced another stop; the pump is already going away.
+ }
+
+ var stopped = true;
+ if (task is not null)
+ {
+ try
+ {
+ await task.WaitAsync(TeardownWait, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is TimeoutException or OperationCanceledException)
+ {
+ stopped = false;
+
+ _logger.LogError(
+ ex,
+ "MTConnect driver {DriverInstanceId} gave up waiting for its /sample pump to stop after {TeardownWait}. The pump is cancelled and can publish nothing further, but something inside it is not returning — the usual cause is an OnDataChange subscriber that blocks. Teardown continues.",
+ _driverInstanceId, TeardownWait);
+ }
+ catch (Exception ex)
+ {
+ // RunSampleStreamAsync is written not to throw, so this is belt-and-braces — but a
+ // teardown that rethrew would replace whatever failure prompted it.
+ _logger.LogDebug(
+ ex,
+ "MTConnect driver {DriverInstanceId} swallowed a fault from its /sample pump while stopping the stream.",
+ _driverInstanceId);
+ }
+ }
+
+ if (stopped)
+ {
+ // Disposed ONLY once the pump is provably gone: an abandoned pump still holds this
+ // source's token, and disposing underneath it turns its next linked-token creation into
+ // an ObjectDisposedException. One leaked CTS beats a mystery exception.
+ cts.Dispose();
+ }
+
+ // Nothing to be degraded about once the stream is gone — UNLESS the endpoint is latched as
+ // unable to stream at all, in which case there is no prospect of one coming back, and
+ // clearing this would let the next successful read report a green driver over a subscription
+ // that can never deliver (#485). See StartSampleStreamCore.
+ lock (_subscriptionLock)
+ {
+ if (!_streamUnsupported)
+ {
+ _streamDegraded = false;
+ }
+ }
+ }
+
+ ///
+ /// Resolves the options a lifecycle call should apply, reporting an unreadable document
+ /// consistently for both entry points before any state is destroyed.
+ ///
+ ///
+ ///
+ /// One rule, deliberately shared by and
+ /// : a config document that cannot be read never
+ /// destroys working state; it faults only a driver that had none.
+ ///
+ ///
+ /// With a live session the driver keeps serving its previous, valid configuration and
+ /// health is left alone — rejecting the edit already fails the deployment
+ /// (DriverInstanceActor turns the throw into ApplyResult(false)), and
+ /// downing a working driver over an unreadable document is a far larger blast radius
+ /// than the change that was refused. Publishing while
+ /// still serving values would also be a lie in the other direction.
+ ///
+ ///
+ /// With no live session there is nothing to protect and every tag is already dark, so
+ /// the failure must reach as —
+ /// otherwise the dashboard keeps showing whatever state the driver was in before, which
+ /// is the failure-that-looks-like-success shape this codebase exists to avoid.
+ ///
+ ///
+ private MTConnectDriverOptions ResolveIncomingOptions(string driverConfigJson)
+ {
+ try
+ {
+ return HasConfigBody(driverConfigJson)
+ ? ParseOptions(_driverInstanceId, driverConfigJson)
+ : _options;
+ }
+ catch (Exception ex)
+ {
+ if (Volatile.Read(ref _session) is not null)
+ {
+ _logger.LogError(
+ ex,
+ "MTConnect driver {DriverInstanceId} rejected a new driver config document and kept running its previous configuration.",
+ _driverInstanceId);
+ }
+ else
+ {
+ WriteHealth(new DriverHealth(DriverState.Faulted, ReadHealth().LastSuccessfulRead, ex.Message));
+ _logger.LogError(
+ ex,
+ "MTConnect driver {DriverInstanceId} could not read its driver config document and has no running configuration to fall back on; the driver is Faulted.",
+ _driverInstanceId);
+ }
+
+ throw;
+ }
+ }
+
+ ///
+ /// True when carries a real config body — i.e. a JSON
+ /// object or array with at least one element. The bootstrapper always passes a
+ /// populated document; tests and probe-only callers pass a semantically empty one, which
+ /// keeps the constructor-supplied options.
+ ///
+ ///
+ ///
+ /// Emptiness is decided by parsing, not by string comparison. Matching the
+ /// literals "{}" / "[]" misses every other spelling of the same document —
+ /// "{ }", a pretty-printed "{\n}", "{ /* nothing yet */ }" — and
+ /// each of those would then reach , fail the required-AgentUri
+ /// check, and turn a semantically empty document into a cold-start fault or a rejected
+ /// deployment. That is precisely the asymmetry the keep-the-constructor-options rule
+ /// exists to prevent.
+ ///
+ ///
+ /// A malformed document is emphatically NOT empty and returns true, so
+ /// runs and produces the real, quoted parse error. Treating
+ /// unreadable text as "no config supplied" would silently swallow a corrupt document and
+ /// start the driver on stale options — a failure wearing success's clothes.
+ ///
+ ///
+ private static bool HasConfigBody(string? driverConfigJson)
+ {
+ if (string.IsNullOrWhiteSpace(driverConfigJson))
+ {
+ return false;
+ }
+
+ try
+ {
+ using var document = JsonDocument.Parse(driverConfigJson, DocumentOptions);
+
+ return document.RootElement.ValueKind switch
+ {
+ JsonValueKind.Object => document.RootElement.EnumerateObject().Any(),
+ JsonValueKind.Array => document.RootElement.EnumerateArray().Any(),
+
+ // A bare `null` document is the JSON spelling of "nothing supplied".
+ JsonValueKind.Null => false,
+
+ // A scalar root is not a config object at all; let ParseOptions say so.
+ _ => true,
+ };
+ }
+ catch (JsonException)
+ {
+ return true;
+ }
+ }
+
+ ///
+ /// Whether the new options change anything reads at
+ /// construction, and therefore cannot be applied to a live client. See
+ /// for why this is wider than the plan's AgentUri/DeviceName.
+ ///
+ private static bool RequiresClientRebuild(MTConnectDriverOptions current, MTConnectDriverOptions incoming) =>
+ !string.Equals(current.AgentUri, incoming.AgentUri, StringComparison.Ordinal)
+ || !string.Equals(current.DeviceName, incoming.DeviceName, StringComparison.Ordinal)
+ || current.RequestTimeoutMs != incoming.RequestTimeoutMs
+ || current.HeartbeatMs != incoming.HeartbeatMs
+ || current.SampleIntervalMs != incoming.SampleIntervalMs
+ || current.SampleCount != incoming.SampleCount;
+
+ ///
+ /// Counts every data item the device model declares. Done once, when the model is cached,
+ /// rather than re-walking the tree on every 30 s poll.
+ ///
+ private static int CountDataItems(MTConnectProbeModel model) =>
+ model.Devices.Sum(d => d.AllDataItems().Count());
+
+ ///
+ /// Runs one unary agent call under a deadline linked to the caller's token. Belt-and-braces
+ /// over the production client's own per-call deadline: the seam does not oblige an
+ /// implementation to bound itself, and an unbounded initialize would wedge the driver-host
+ /// actor (arch-review R2-01).
+ ///
+ private async Task BoundedAsync(
+ Func> call, string request, int timeoutMs, CancellationToken ct)
+ {
+ using var deadline = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ deadline.CancelAfter(TimeSpan.FromMilliseconds(timeoutMs));
+
+ try
+ {
+ return await call(deadline.Token).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (!ct.IsCancellationRequested)
+ {
+ // A bare "A task was canceled" names neither the Agent nor the request that stalled.
+ throw new TimeoutException(
+ $"MTConnect {request} request for driver '{_driverInstanceId}' against '{_options.AgentUri}' did not complete within {timeoutMs} ms.");
+ }
+ }
+
+ ///
+ /// One Bad-coded snapshot per requested reference — the shape every read failure degrades
+ /// to. Arity is preserved because the caller indexes the result positionally against the
+ /// references it asked for.
+ ///
+ private static DataValueSnapshot[] BadBatch(int count, uint status)
+ {
+ // No source timestamp: nothing was observed. The server timestamp is when we gave up.
+ var serverTimestamp = DateTime.UtcNow;
+ var results = new DataValueSnapshot[count];
+
+ for (var i = 0; i < count; i++)
+ {
+ results[i] = new DataValueSnapshot(null, status, null, serverTimestamp);
+ }
+
+ return results;
+ }
+
+ ///
+ /// Routes a read failure to the health surface (05/STAB-9's fleet-wide shape): log it and
+ /// degrade, preserving LastSuccessfulRead — the "when did this driver last hear from
+ /// the Agent" diagnostic. Never downgrades , which is a
+ /// stronger, config-level signal that a transient read failure must not paper over.
+ ///
+ private void DegradeAfterReadFailure(Exception ex)
+ {
+ _readDegraded = true;
+ Degrade(ex);
+
+ _logger.LogWarning(
+ ex,
+ "MTConnect driver {DriverInstanceId} could not read /current from {AgentUri}; the batch is reported Bad.",
+ _driverInstanceId, _options.AgentUri);
+ }
+
+ /// Publishes without downgrading a Faulted driver.
+ private void Degrade(Exception ex) => Degrade(ex.Message);
+
+ ///
+ /// Publishes with an explanatory message, without
+ /// downgrading a Faulted driver.
+ ///
+ ///
+ /// Deliberately not , even for the "an operator must fix
+ /// this" cases such as an endpoint that cannot stream. In this codebase Faulted is not
+ /// merely a stronger word: DriverHealthReport maps any Faulted driver to a
+ /// /readyz503, taking the whole server node out of readiness. A driver whose
+ /// reads are perfectly healthy and whose subscriptions are dead is degraded, not unready —
+ /// de-readying the node over one misconfigured Agent would be a far larger blast radius than
+ /// the fault it reports. Faulted stays reserved for the case that earns it: an initialize
+ /// that failed, after which the driver serves nothing at all.
+ ///
+ private void Degrade(string message)
+ {
+ var current = ReadHealth();
+ if (current.State != DriverState.Faulted)
+ {
+ WriteHealth(new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, message));
+ }
+ }
+
+ ///
+ /// Publishes — unless the other data path is currently
+ /// failing.
+ ///
+ ///
+ ///
+ /// Health precedence, stated deliberately. This driver has two independent Agent
+ /// data paths: /current (the batch read) and
+ /// /sample (the subscription pump). They are different HTTP requests and fail
+ /// separately — a proxy that collapses multipart/x-mixed-replace breaks streaming
+ /// while reads stay perfect; an Agent that stalls composing a whole-device snapshot
+ /// breaks reads while the stream keeps flowing.
+ ///
+ ///
+ /// The naive last-writer-wins publish would let either path paper over the other's
+ /// failure, and "Healthy" while half the surface returns Bad is precisely the
+ /// failure-wearing-success shape this codebase exists to avoid. So each path owns a
+ /// flag, clears only its own, and Healthy requires both clear: the driver reports
+ /// Degraded while anything is failing and recovers as soon as the failing path
+ /// itself recovers. — a config-level verdict — is
+ /// never downgraded by either.
+ ///
+ ///
+ /// When the Agent was last successfully heard from.
+ private void PublishHealthy(DateTime at)
+ {
+ var current = ReadHealth();
+
+ if (_streamDegraded || _readDegraded)
+ {
+ if (current.State != DriverState.Faulted)
+ {
+ WriteHealth(new DriverHealth(DriverState.Degraded, at, current.LastError));
+ }
+
+ return;
+ }
+
+ WriteHealth(new DriverHealth(DriverState.Healthy, at, null));
+ }
+
+ ///
+ /// Disposes an agent client without letting a disposal fault escape. Teardown runs on the
+ /// failure path of , where a second exception would replace the
+ /// real one — but the swallowed exception is still traced rather than vanishing: a
+ /// client that cannot be released is how a socket-handle leak starts, and an empty catch is
+ /// the only place in this driver where a failure would leave no evidence at all. Debug
+ /// level, because it is diagnostic detail about an already-reported failure.
+ ///
+ private void SafeDispose(IMTConnectAgentClient? client)
+ {
+ if (client is null)
+ {
+ return;
+ }
+
+ try
+ {
+ client.Dispose();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(
+ ex,
+ "MTConnect driver {DriverInstanceId} swallowed a fault while disposing its agent client during teardown; the client may not have released its connections.",
+ _driverInstanceId);
+ }
+ }
+
+ private static void RequirePositive(int value, string optionName)
+ {
+ if (value <= 0)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(MTConnectDriverOptions),
+ value,
+ $"{optionName} must be positive; a non-positive value would either un-bound a deadline or ask the Agent for nothing.");
+ }
+ }
+
+ ///
+ /// The half of the 01/S-6 guard, for the connectivity probe's two
+ /// knobs. A non-positive interval is an unthrottled loop; a non-positive timeout cancels
+ /// every request before it can be answered.
+ ///
+ private static void RequirePositive(TimeSpan value, string optionName)
+ {
+ if (value <= TimeSpan.Zero)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(MTConnectDriverOptions),
+ value,
+ $"{optionName} must be positive; a non-positive value would either spin the connectivity probe against the Agent or cancel every probe before it could be answered.");
+ }
+ }
+
+ /// Barrier-protected read of the cross-thread health snapshot.
+ private DriverHealth ReadHealth() => Volatile.Read(ref _health);
+
+ /// Barrier-protected publish of a new health snapshot.
+ private void WriteHealth(DriverHealth value) => Volatile.Write(ref _health, value);
+
+ private static MTConnectTagDefinition BuildTag(MTConnectTagDto dto, string driverInstanceId)
+ {
+ if (string.IsNullOrWhiteSpace(dto.FullName))
+ {
+ throw new InvalidOperationException(
+ $"MTConnect driver config for '{driverInstanceId}' has a tag with no FullName (the Agent DataItem id it binds).");
+ }
+
+ return new MTConnectTagDefinition(
+ dto.FullName.Trim(),
+ // Absent means "no declared type": String is the one coercion that cannot fail, and it
+ // carries the Agent's text through untouched.
+ dto.DriverDataType is null
+ ? DriverDataType.String
+ : ParseEnum(dto.DriverDataType, dto.FullName, driverInstanceId, nameof(MTConnectTagDefinition.DriverDataType)),
+ dto.IsArray ?? false,
+ dto.ArrayDim ?? 0,
+ dto.MtCategory,
+ dto.MtType,
+ dto.MtSubType,
+ dto.Units);
+ }
+
+ // ---- v3 tag binding: RawPath ↔ dataItemId, and the definitions the index coerces against ----
+
+ ///
+ /// Derives the driver's tag tables from an options document — the RawPath → dataItemId
+ /// translation the data plane runs through, its reverse, and the definition set the
+ /// observation index coerces against.
+ ///
+ ///
+ ///
+ /// A bad raw tag is skipped, never thrown. One unreadable TagConfig blob
+ /// must not fail a whole driver — and therefore a whole deployment — over a tag the rest
+ /// of the plant does not depend on. The skipped tag's RawPath then resolves to nothing
+ /// and reports BadNodeIdUnknown, which is the documented
+ /// posture ("a miss is a miss") and is
+ /// visible to an operator, unlike a silent default.
+ ///
+ ///
+ /// Coercion type precedence (see ):
+ /// the blob's own driverDataType, else a
+ /// entry naming the same DataItem id, else the Agent's /probe declaration for that
+ /// data item, else . The third step is what makes a
+ /// browse-committed tag work: RawBrowseCommitMapper writes only the address into
+ /// the blob, so without it every value from the address picker would publish as text
+ /// under a numerically-typed OPC UA node.
+ ///
+ ///
+ /// The options document to derive from.
+ /// The Agent's device model when one has been fetched; else null.
+ private TagBinding BuildBinding(MTConnectDriverOptions options, MTConnectProbeModel? probeModel)
+ {
+ // The dataItemId-keyed authoring surface first (the driver CLI, and every pre-v3 caller).
+ // Last-wins on a duplicate id, matching MTConnectObservationIndex's own documented rule.
+ var definitions = new Dictionary(StringComparer.Ordinal);
+ foreach (var tag in options.Tags)
+ {
+ if (tag is null || string.IsNullOrWhiteSpace(tag.FullName)) continue;
+
+ definitions[tag.FullName] = tag;
+ }
+
+ if (options.RawTags.Count == 0)
+ {
+ return new TagBinding(
+ FrozenDictionary.Empty,
+ FrozenDictionary>.Empty,
+ [.. definitions.Values]);
+ }
+
+ var declared = IndexProbeDataItems(probeModel);
+ var idByRawPath = new Dictionary(options.RawTags.Count, StringComparer.Ordinal);
+ var rawPathsById = new Dictionary>(StringComparer.Ordinal);
+
+ foreach (var entry in options.RawTags)
+ {
+ if (entry is null || string.IsNullOrWhiteSpace(entry.RawPath)) continue;
+
+ if (!TryReadRawTag(entry, out var dataItemId, out var authored))
+ {
+ _logger.LogWarning(
+ "MTConnect driver {DriverInstanceId} could not map the raw tag {RawPath} to an Agent DataItem; it names no readable id (expected 'fullName', 'dataItemId' or 'address') or declares an unrecognised driverDataType. The tag is skipped and reports BadNodeIdUnknown; every other tag is unaffected.",
+ _driverInstanceId, entry.RawPath);
+
+ continue;
+ }
+
+ idByRawPath[entry.RawPath] = dataItemId;
+
+ if (!rawPathsById.TryGetValue(dataItemId, out var paths))
+ {
+ rawPathsById[dataItemId] = paths = [];
+ }
+
+ if (!paths.Contains(entry.RawPath, StringComparer.Ordinal))
+ {
+ // One DataItem may legitimately back several authored raw tags; a duplicate RawPath
+ // (the same tag listed twice) must still publish once.
+ paths.Add(entry.RawPath);
+ }
+
+ if (authored is not null)
+ {
+ // The blob declared a type: it is the deploy-time authority and supersedes an older
+ // Tags entry for the same DataItem.
+ definitions[dataItemId] = authored;
+ }
+ else if (!definitions.ContainsKey(dataItemId))
+ {
+ definitions[dataItemId] = InferDefinition(dataItemId, declared);
+ }
+ }
+
+ return new TagBinding(
+ idByRawPath.ToFrozenDictionary(StringComparer.Ordinal),
+ rawPathsById.ToFrozenDictionary(
+ pair => pair.Key,
+ pair => (IReadOnlyList)pair.Value,
+ StringComparer.Ordinal),
+ [.. definitions.Values]);
+ }
+
+ ///
+ /// Reads one authored raw tag: the DataItem id it binds, and the definition its blob declares
+ /// (null when the blob declares no type and the caller must fall back).
+ ///
+ ///
+ /// when the blob is unreadable, names no DataItem id, or carries a
+ /// driverDataType that is not a — a present-but-invalid
+ /// type rejects the tag rather than silently defaulting, which would publish the Agent's text
+ /// as a Good value of the wrong type (the sibling drivers' strict-enum posture).
+ ///
+ private static bool TryReadRawTag(
+ RawTagEntry entry, out string dataItemId, out MTConnectTagDefinition? authored)
+ {
+ dataItemId = string.Empty;
+ authored = null;
+
+ if (string.IsNullOrWhiteSpace(entry.TagConfig)) return false;
+
+ MTConnectRawTagConfigDto? blob;
+ try
+ {
+ blob = JsonSerializer.Deserialize(entry.TagConfig, JsonOptions);
+ }
+ catch (JsonException)
+ {
+ return false;
+ }
+
+ if (blob is null) return false;
+
+ var id = FirstNonBlank(blob.FullName, blob.DataItemId, blob.Address);
+ if (id is null) return false;
+
+ dataItemId = id;
+
+ // "driverDataType" (the driver's own tags[] field) or "dataType" (what the AdminUI's typed
+ // MTConnectTagConfigModel writes) — see the DTO remarks.
+ var authoredType = FirstNonBlank(blob.DriverDataType, blob.DataType);
+ if (authoredType is null) return true;
+
+ // A digits-only type is rejected outright, BEFORE the parse: Enum.TryParse accepts "8" as
+ // readily as "Float64" and returns the member with that ordinal, so a numerically-serialized
+ // enum (the repo-wide authoring trap) would otherwise coerce every value of that tag to a
+ // type nobody chose, under Good quality. The AdminUI's MTConnectTagConfigModel.Validate
+ // refuses the same shape; this is the runtime half of that guard.
+ if (authoredType.All(char.IsAsciiDigit)
+ || !Enum.TryParse(authoredType, ignoreCase: true, out var parsed)
+ || !Enum.IsDefined(parsed))
+ {
+ return false;
+ }
+
+ authored = new MTConnectTagDefinition(
+ dataItemId,
+ parsed,
+ blob.IsArray ?? false,
+ blob.ArrayDim ?? 0,
+ blob.MtCategory,
+ blob.MtType,
+ blob.MtSubType,
+ blob.Units);
+
+ return true;
+ }
+
+ ///
+ /// The definition for a raw tag whose blob declared no type: the Agent's own /probe
+ /// declaration when the model is in hand, else — the one
+ /// coercion that cannot fail, carrying the Agent's text through untouched.
+ ///
+ private static MTConnectTagDefinition InferDefinition(
+ string dataItemId, FrozenDictionary declared)
+ {
+ if (!declared.TryGetValue(dataItemId, out var dataItem))
+ {
+ return new MTConnectTagDefinition(dataItemId, DriverDataType.String);
+ }
+
+ var inferred = MTConnectDataTypeInference.Infer(
+ dataItem.Category, dataItem.Type, dataItem.Units, dataItem.Representation, dataItem.SampleCount);
+
+ return new MTConnectTagDefinition(
+ dataItemId,
+ inferred.DataType,
+ inferred.IsArray,
+ (int)(inferred.ArrayDim ?? 0),
+ dataItem.Category,
+ dataItem.Type,
+ dataItem.SubType,
+ dataItem.Units);
+ }
+
+ /// Flattens a device model into a dataItemId → DataItem lookup; null ⇒ empty.
+ private static FrozenDictionary IndexProbeDataItems(MTConnectProbeModel? probeModel)
+ {
+ if (probeModel is null) return FrozenDictionary.Empty;
+
+ var map = new Dictionary(StringComparer.Ordinal);
+ foreach (var device in probeModel.Devices)
+ {
+ if (device is null) continue;
+
+ foreach (var dataItem in device.AllDataItems())
+ {
+ if (dataItem is null || string.IsNullOrWhiteSpace(dataItem.Id)) continue;
+
+ map[dataItem.Id] = dataItem;
+ }
+ }
+
+ return map.ToFrozenDictionary(StringComparer.Ordinal);
+ }
+
+ /// The first of that carries text, trimmed; else null.
+ private static string? FirstNonBlank(params string?[] candidates)
+ {
+ foreach (var candidate in candidates)
+ {
+ if (!string.IsNullOrWhiteSpace(candidate)) return candidate.Trim();
+ }
+
+ return null;
+ }
+
+ ///
+ /// Parses an enum authored as a name. Config surfaces serialize enums as names
+ /// project-wide; a numerically-serialized enum is the known trap that faults a driver at
+ /// deploy, so this reports the offending field rather than silently taking member 0.
+ ///
+ private static T ParseEnum(string raw, string tagName, string driverInstanceId, string field)
+ where T : struct, Enum =>
+ Enum.TryParse(raw, ignoreCase: true, out var parsed) && Enum.IsDefined(parsed)
+ ? parsed
+ : throw new InvalidOperationException(string.Create(
+ CultureInfo.InvariantCulture,
+ $"MTConnect driver config for '{driverInstanceId}' tag '{tagName}' has an unrecognised {field} '{raw}'. Expected one of: {string.Join(", ", Enum.GetNames())}."));
+
+ ///
+ /// The live agent client and the /probe cache derived from it, as one immutable
+ /// unit. Immutable so that publishing a change is a single reference swap — see the
+ /// remarks for why the three values must move together.
+ ///
+ /// The agent client this session owns and will dispose on teardown.
+ ///
+ /// The cached device model, or null once has
+ /// dropped it. Never null at the moment a session is first published.
+ ///
+ ///
+ /// Data items declared by , counted once at cache time and
+ /// always 0 when it is null.
+ ///
+ ///
+ /// The Agent's instanceId as last observed. Updated in place (by
+ /// ) when the pump sees the Agent restart, so the
+ /// session always names the Agent process the client is actually talking to.
+ ///
+ ///
+ /// The sequence the /sample pump should open at — the priming /current's
+ /// nextSequence. Lives here so that starting the pump reads the client and its cursor
+ /// as one consistent pair; a torn read would open the stream at another session's sequence.
+ ///
+ private sealed record AgentSession(
+ IMTConnectAgentClient Client,
+ MTConnectProbeModel? ProbeModel,
+ int ProbeModelDataItemCount,
+ long InstanceId,
+ long NextSequence);
+
+ ///
+ /// The tag tables one options document derives, as one immutable unit — so installing new
+ /// options is a single reference swap and no reader can pair one document's RawPaths with
+ /// another's definitions.
+ ///
+ ///
+ /// The v3 wire translation: the RawPath the server subscribes / reads / routes by, mapped to
+ /// the Agent DataItem id the observation index is keyed by.
+ ///
+ ///
+ /// The reverse, one-to-many: every RawPath a DataItem must be published under. A DataItem may
+ /// legitimately back more than one authored raw tag, and each of those tags is a separate
+ /// OPC UA node that must receive the value.
+ ///
+ ///
+ /// The definition set coerces against — the union of
+ /// the authored and the definitions derived from
+ /// . This is how a raw tag's declared (or
+ /// probe-inferred) DriverDataType reaches the coercion.
+ ///
+ private sealed record TagBinding(
+ FrozenDictionary DataItemIdByRawPath,
+ FrozenDictionary> RawPathsByDataItemId,
+ IReadOnlyList IndexTags)
+ {
+ ///
+ /// Translates a subscriber/read reference to the DataItem id the index is keyed by.
+ ///
+ ///
+ /// An unmapped reference is returned unchanged rather than rejected, which serves
+ /// two callers at once: the dataItemId-keyed authoring surface (the driver CLI, and a
+ /// driver configured with Tags and no RawTags) resolves normally, and a
+ /// RawPath that no authored raw tag claims falls through to an index miss —
+ /// BadNodeIdUnknown, never an exception. There is no third behaviour to add here:
+ /// the index already distinguishes "authored but not yet reported" from "unknown".
+ ///
+ /// The caller's reference (a RawPath under v3).
+ /// The DataItem id to look up.
+ public string ToDataItemId(string? reference) =>
+ reference is not null && DataItemIdByRawPath.TryGetValue(reference, out var dataItemId)
+ ? dataItemId
+ : reference ?? string.Empty;
+
+ ///
+ /// Appends every reference must be published under: each
+ /// RawPath bound to it, plus the id itself for the dataItemId-keyed authoring surface.
+ ///
+ /// The DataItem the Agent just reported.
+ /// The reference list being accumulated for the fan-out.
+ public void AppendReferences(string dataItemId, List into)
+ {
+ if (RawPathsByDataItemId.TryGetValue(dataItemId, out var rawPaths))
+ {
+ into.AddRange(rawPaths);
+
+ // Only when a RawPath is spelled identically to the id — otherwise a subscriber
+ // holding that one reference would be raised twice for one observation.
+ if (rawPaths.Contains(dataItemId, StringComparer.Ordinal)) return;
+ }
+
+ into.Add(dataItemId);
+ }
+ }
+
+ ///
+ /// One live subscription: its handle and the references it wants. Both are effectively
+ /// immutable once registered, so the pump reads without a lock —
+ /// it takes only to snapshot the registry itself.
+ ///
+ /// The identity handed to the caller and stamped on every event.
+ /// The de-duplicated DataItem ids this subscription publishes.
+ private sealed record SubscriptionState(MTConnectSampleHandle Handle, HashSet References);
+
+ /// How one /sample connection ended, and therefore what the pump does next.
+ private enum StreamVerdict
+ {
+ /// Cancelled by teardown — stop, publish nothing, report nothing.
+ Cancelled = 0,
+
+ /// A transient failure: degrade and reconnect under the backoff.
+ Transient = 1,
+
+ /// Re-baselined successfully: reopen immediately at the new cursor, no backoff.
+ Reopen = 2,
+
+ /// The endpoint cannot stream at all: latch, report loudly, and stop retrying.
+ Unsupported = 3
+ }
+
+ /// How the connection ended.
+ /// The sequence the next connection must open at.
+ /// The Agent instance the cursor belongs to.
+ ///
+ /// Whether this connection yielded at least one chunk before it ended. Distinguishes a
+ /// working endpoint having a bad moment from one that has never worked, which is what keeps
+ /// the reconnect backoff from ratcheting monotonically over a process lifetime.
+ ///
+ /// The failure, when there was one.
+ private sealed record StreamOutcome(
+ StreamVerdict Verdict, long Cursor, long InstanceId, bool DeliveredAny, Exception? Error);
+
+ // ---- config DTOs ----
+
+ ///
+ /// Nullable-init mirror of the driver's DriverConfig JSON. Every property is nullable
+ /// so an omitted key means "use the default" rather than "reset to zero".
+ ///
+ private sealed class MTConnectDriverConfigDto
+ {
+ public string? AgentUri { get; init; }
+ public string? DeviceName { get; init; }
+ public int? RequestTimeoutMs { get; init; }
+ public int? SampleIntervalMs { get; init; }
+ public int? SampleCount { get; init; }
+ public int? HeartbeatMs { get; init; }
+ public List? Tags { get; init; }
+
+ ///
+ /// The v3 authored raw tags the deploy artifact injects (RawPath + driver TagConfig
+ /// blob). Bound verbatim into ; this is the
+ /// collection a deployed instance's data plane is keyed by.
+ ///
+ public List? RawTags { get; init; }
+ public MTConnectProbeDto? Probe { get; init; }
+ public MTConnectReconnectDto? Reconnect { get; init; }
+ }
+
+ ///
+ /// A raw tag's TagConfig blob: the driver-specific address of one authored raw tag.
+ /// Every field is optional — the blob is operator-authorable and reaches the driver through
+ /// several authoring surfaces that populate different subsets of it.
+ ///
+ ///
+ ///
+ /// Three accepted spellings of the DataItem id, tried in order:
+ /// fullName (the driver's own tag shape, and what the typed editor writes),
+ /// dataItemId (the protocol's own word for it, and the spelling an operator
+ /// hand-authoring the blob reaches for first), and address (what
+ /// RawBrowseCommitMapper writes for a driver with no typed editor of its own — the
+ /// shape every browse-committed MTConnect tag currently arrives in). Accepting all three
+ /// costs two dictionary probes; rejecting two of them would make a tag committed from the
+ /// address picker silently unreadable.
+ ///
+ ///
+ /// Two accepted spellings of the coercion type, likewise: dataType — what
+ /// the AdminUI's MTConnectTagConfigModel writes, and therefore what every tag
+ /// authored through the typed editor carries — and driverDataType, matching the
+ /// driver's own tags[] field name. Reading only one of them would make the editor
+ /// and the driver disagree about the type of every tag.
+ ///
+ ///
+ /// Kept separate from rather than reusing it: that DTO is
+ /// the strict tags[] surface, where a missing fullName is a config error
+ /// worth failing the deployment over. A raw tag's blob is authored elsewhere and its
+ /// failure mode is per-tag, not per-driver.
+ ///
+ ///
+ private sealed class MTConnectRawTagConfigDto
+ {
+ public string? FullName { get; init; }
+ public string? DataItemId { get; init; }
+ public string? Address { get; init; }
+ public string? DriverDataType { get; init; }
+ public string? DataType { get; init; }
+ public bool? IsArray { get; init; }
+ public int? ArrayDim { get; init; }
+ public string? MtCategory { get; init; }
+ public string? MtType { get; init; }
+ public string? MtSubType { get; init; }
+ public string? Units { get; init; }
+ }
+
+ /// One authored tag. DriverDataType stays a string — see .
+ private sealed class MTConnectTagDto
+ {
+ public string? FullName { get; init; }
+ public string? DriverDataType { get; init; }
+ public bool? IsArray { get; init; }
+ public int? ArrayDim { get; init; }
+ public string? MtCategory { get; init; }
+ public string? MtType { get; init; }
+ public string? MtSubType { get; init; }
+ public string? Units { get; init; }
+ }
+
+ /// Background connectivity-probe knobs (Task 13 consumes them).
+ private sealed class MTConnectProbeDto
+ {
+ public bool? Enabled { get; init; }
+ public int? IntervalMs { get; init; }
+ public int? TimeoutMs { get; init; }
+ }
+
+ /// Reconnect-backoff knobs (Task 11's pump consumes them).
+ private sealed class MTConnectReconnectDto
+ {
+ public int? MinBackoffMs { get; init; }
+ public int? MaxBackoffMs { get; init; }
+ public double? BackoffMultiplier { get; init; }
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriverFactoryExtensions.cs
new file mode 100644
index 00000000..40faa2bd
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriverFactoryExtensions.cs
@@ -0,0 +1,116 @@
+using Microsoft.Extensions.Logging;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Core.Hosting;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
+
+///
+/// Static factory registration helper for . The Host registers it
+/// once at startup; the bootstrapper then materialises MTConnect DriverInstance rows from
+/// the deployed configuration into live driver instances. Mirrors
+/// ModbusDriverFactoryExtensions / FocasDriverFactoryExtensions.
+///
+///
+///
+/// There is exactly one parser for the config document. This factory does not carry a
+/// config DTO of its own — it delegates to , which
+/// the driver itself must own because the runtime delivers a config change to a live
+/// instance (DriverInstanceActor assigns its driver once and calls
+/// ReinitializeAsync(newJson) thereafter). A second DTO here would drift from that
+/// one silently, and the drift would first show up at deploy time as an operator's edit
+/// being applied differently by the two paths — or not at all.
+///
+///
+/// Construction opens nothing. The Wave-0 universal discovery browser builds a
+/// throwaway instance through this factory purely to read
+/// ITagDiscovery.SupportsOnlineDiscovery, and never initializes it — so a factory
+/// that dialled the Agent (or that pre-built an ) would
+/// open a connection per AdminUI browse render against a possibly-unreachable Agent. The
+/// agent-client factory is therefore left null: builds the
+/// production client inside InitializeAsync.
+///
+///
+/// The instance is returned as its concrete type. The runtime resolves every optional
+/// capability (, ,
+/// , ,
+/// ) by pattern-matching the object
+/// hands back, so nothing here may narrow it — a
+/// narrowed return is how a capability ends up implemented but never dispatched. There is
+/// deliberately no IWritable: the MTConnect Agent surface is read-only.
+///
+///
+public static class MTConnectDriverFactoryExtensions
+{
+ ///
+ /// The DriverInstance.DriverType value this factory answers to.
+ ///
+ ///
+ /// Aliased to , not a literal. The registry key,
+ /// the instance's self-reported , the probe's
+ /// DriverType, and the constant every dispatch map keys off are therefore the same
+ /// symbol — the repo-wide fix for the historical TwinCat/Focas drift, where a
+ /// driver's own spelling silently diverged from the constant and dispatch maps missed it.
+ /// Retained as a named const (rather than deleted in favour of the constant) so the existing
+ /// factory callers and the sibling *DriverFactoryExtensions.DriverTypeName convention
+ /// keep working; it is now an alias with no independent value.
+ ///
+ public const string DriverTypeName = DriverTypeNames.MTConnect;
+
+ ///
+ /// Register the MTConnect factory with the driver registry. The optional
+ /// is captured at registration time and used to construct
+ /// an per driver instance — without it the driver runs
+ /// with the null logger (tests and standalone callers stay unchanged).
+ ///
+ /// The driver factory registry to register with.
+ /// Optional logger factory for creating loggers per driver instance.
+ public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)
+ {
+ ArgumentNullException.ThrowIfNull(registry);
+ registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory));
+ }
+
+ /// Public for the Server-side bootstrapper + test consumers.
+ /// The unique identifier for the driver instance.
+ /// The driver's DriverConfig JSON.
+ /// The constructed instance.
+ public static MTConnectDriver CreateInstance(string driverInstanceId, string driverConfigJson)
+ => CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null);
+
+ /// Logger-aware overload — used by 's closure when wired through DI.
+ /// The unique identifier for the driver instance.
+ /// The driver's DriverConfig JSON.
+ /// Optional logger factory for creating loggers per driver instance.
+ /// The constructed instance.
+ ///
+ /// or is null or blank.
+ ///
+ ///
+ /// The config document is unparseable, deserialises to null, omits the required
+ /// agentUri, or carries an unauthorable enum/tag value. Throwing is correct at this
+ /// seam: a deployment carrying such a row must fail rather than register a driver pointed at
+ /// nothing, and the discovery browser's CanBrowse catches factory exceptions so a
+ /// half-authored config merely disables the address picker.
+ ///
+ public static MTConnectDriver CreateInstance(
+ string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
+ ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);
+
+ // The single config authority — see the class remarks for why this is not a second DTO.
+ // Note the asymmetry with the driver's own lifecycle path: an empty ("{}" / "[]") document
+ // there means "no config supplied, keep the constructor options", but this factory HAS no
+ // constructor options to keep, so an empty document is simply a config with no agentUri and
+ // must fault.
+ var options = MTConnectDriver.ParseOptions(driverInstanceId, driverConfigJson);
+
+ // Named arguments deliberately: the ctor's trailing parameters are all optional, so a
+ // future insertion could silently re-bind a positional argument to the wrong one.
+ return new MTConnectDriver(
+ options,
+ driverInstanceId,
+ agentClientFactory: null,
+ logger: loggerFactory?.CreateLogger());
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriverProbe.cs
new file mode 100644
index 00000000..85658b9a
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriverProbe.cs
@@ -0,0 +1,237 @@
+using System.Diagnostics;
+using System.Net.Http;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
+
+///
+/// One-shot MTConnect Agent reachability probe for the AdminUI's Test Connect button. Issues a
+/// single GET {AgentUri}/probe under the supplied deadline and reports success/failure
+/// with latency — it does not construct or initialize an instance.
+///
+///
+///
+/// Deliberately does NOT delegate to . That
+/// method builds the full driver options document, including every authored
+/// MTConnectTagDefinition (BuildTag can throw on a malformed tag entry that has
+/// nothing to do with reachability). A probe only needs agentUri, so this type parses
+/// its own minimal DTO — a config that is invalid for the driver (bad tag, bad data type) can
+/// still be reachability-tested, matching the AdminUI's "does the endpoint answer" intent for
+/// Test Connect. It also avoids a compile-time dependency on the factory DTO shape
+/// (MTConnectDriverFactoryExtensions), which is authored separately (Task 15).
+///
+///
+/// Accepted gap: reachable ≠ deployable. Because only agentUri is read, a
+/// config can show green on Test Connect and still fail to deploy — e.g. a non-positive
+/// requestTimeoutMs or a malformed tag entry the factory's full parse would reject.
+/// This is the direct consequence of the independent-parsing decision above, judged
+/// acceptable because Test Connect's job is "is the endpoint there", not "will this exact
+/// config deploy".
+///
+///
+/// Deliberately DOES reuse and
+/// for the actual round trip. Hand-rolling the HTTP
+/// call would have to re-derive the exact behaviour the client already provides: per-call
+/// bounding, a linked-CTS deadline, and — most importantly —
+/// correct handling of an Agent's MTConnectError document served under HTTP 200 (so
+/// EnsureSuccessStatusCode never fires). already
+/// lifts the Agent's own error text into the thrown exception's message; re-parsing by hand
+/// here would either duplicate that logic or under-validate (accepting any 200 response as
+/// healthy, which is precisely the "empty-but-successful" defect class this codebase has hit
+/// before, #485). The one-off cost of a full probe-document parse is negligible next to that
+/// risk.
+///
+///
+/// Never throws (the contract): every failure — unparseable
+/// JSON, missing/malformed agentUri, DNS/TCP failure, a non-2xx response, a 200 body
+/// that is not a valid MTConnectDevices document, and timeout — becomes
+/// Ok = false with a message. Genuine caller cancellation is likewise converted rather
+/// than left to propagate, matching ModbusDriverProbe's posture (its linked-CTS catch
+/// does not distinguish the caller's token from its own deadline).
+///
+///
+/// Non-positive timeout (arch-review 01/S-6). A 0 or negative
+/// timeout does NOT mean "wait forever" — it is replaced with
+/// (5s, matching 's own default) so an
+/// operator-authorable field can never brick the probe UI.
+///
+///
+/// Secrets discipline.AgentUri may carry userinfo credentials
+/// (http://user:pass@host/). Every message this type constructs from the URI uses a
+/// redacted display form () built ONLY from
+/// ///
+/// — never and never the raw config string — so a password can
+/// never reach the AdminUI via the returned .
+///
+///
+public sealed class MTConnectDriverProbe : IDriverProbe
+{
+ /// Replaces a non-positive caller timeout — see the type remarks.
+ private static readonly TimeSpan FallbackTimeout = TimeSpan.FromSeconds(5);
+
+ ///
+ /// Caps an excessive caller timeout. CancellationTokenSource.CancelAfter(TimeSpan)'s
+ /// legal range tops out near ~49.7 days ( ms minus one); an
+ /// uncapped pathological value (e.g. a caller passing TimeSpan.FromDays(1000)) throws
+ /// straight out of
+ /// CancelAfter, which would violate the "Never throws" contract just as surely as a
+ /// non-positive timeout meaning "wait forever" would (arch-review 01/S-6 — this is the same
+ /// defect class, the high end rather than the low end). 10 minutes is generous for a
+ /// reachability probe and leaves an enormous margin under the legal ceiling.
+ ///
+ private static readonly TimeSpan MaxTimeout = TimeSpan.FromMinutes(10);
+
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
+ Converters = { new JsonStringEnumConverter() },
+ };
+
+ /// Test seam: routes the reused through a stub handler so response-shape cases need no socket.
+ private readonly HttpMessageHandler? _handler;
+
+ /// Creates the probe used in production (real sockets).
+ public MTConnectDriverProbe()
+ : this(handler: null)
+ {
+ }
+
+ /// Test seam constructor — see .
+ internal MTConnectDriverProbe(HttpMessageHandler? handler)
+ {
+ _handler = handler;
+ }
+
+ ///
+ ///
+ /// Sourced from the constant, not a literal — the probe is
+ /// looked up by AdminOperationsActor under this key, so a drift from the constant
+ /// silently disables the Test Connect button rather than failing anything at build time.
+ ///
+ public string DriverType => DriverTypeNames.MTConnect;
+
+ /// Minimal probe-only config shape. Deliberately independent of the factory DTO — see the type remarks.
+ private sealed class MTConnectProbeConfigDto
+ {
+ public string? AgentUri { get; set; }
+ }
+
+ ///
+ public async Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
+ {
+ MTConnectProbeConfigDto? dto;
+ try
+ {
+ dto = JsonSerializer.Deserialize(configJson, JsonOptions);
+ }
+ catch (Exception ex)
+ {
+ return new(false, $"Config JSON is invalid: {ex.Message}", null);
+ }
+
+ if (dto is null)
+ {
+ return new(false, "Config JSON deserialized to null.", null);
+ }
+
+ var agentUri = dto.AgentUri?.Trim();
+ if (string.IsNullOrWhiteSpace(agentUri))
+ {
+ return new(false, "Config has no agentUri to probe.", null);
+ }
+
+ if (!Uri.TryCreate(agentUri, UriKind.Absolute, out var parsed) ||
+ (parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps))
+ {
+ return new(false, "Config's agentUri is not an absolute http(s) URI.", null);
+ }
+
+ var safeUri = RedactedDisplay(parsed);
+
+ // arch-review 01/S-6: a non-positive timeout must never mean "wait forever" (low end), and a
+ // pathologically large one must never overrun CancelAfter's legal range (high end) — see
+ // MaxTimeout.
+ var effectiveTimeout = timeout > TimeSpan.Zero ? timeout : FallbackTimeout;
+ if (effectiveTimeout > MaxTimeout)
+ {
+ effectiveTimeout = MaxTimeout;
+ }
+
+ var requestTimeoutMs = Math.Max(1, (int)Math.Ceiling(effectiveTimeout.TotalMilliseconds));
+
+ var sw = Stopwatch.StartNew();
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ cts.CancelAfter(effectiveTimeout);
+
+ var options = new MTConnectDriverOptions
+ {
+ AgentUri = agentUri,
+ RequestTimeoutMs = requestTimeoutMs,
+ };
+
+ try
+ {
+ using var client = new MTConnectAgentClient(options, _handler);
+ _ = await client.ProbeAsync(cts.Token).ConfigureAwait(false);
+ sw.Stop();
+
+ return new(true, $"MTConnect /probe OK ({safeUri})", sw.Elapsed);
+ }
+ catch (OperationCanceledException)
+ {
+ // Covers both the caller's token and our own CancelAfter deadline firing — see the type
+ // remarks on why cancellation is never allowed to propagate.
+ return new(false, $"Probe timed out after {effectiveTimeout.TotalSeconds:F0}s.", null);
+ }
+ catch (TimeoutException)
+ {
+ // MTConnectAgentClient's own request-deadline signal.
+ return new(false, $"Probe timed out after {effectiveTimeout.TotalSeconds:F0}s.", null);
+ }
+ catch (HttpRequestException ex)
+ {
+ // Connection failure or non-2xx status. HttpRequestException messages describe the
+ // socket endpoint or status code, never the request URI/userinfo, so ex.Message is safe.
+ return new(false, $"Request to {safeUri} failed: {ex.Message}", null);
+ }
+ catch (InvalidDataException ex)
+ {
+ // MTConnectProbeParser: not well-formed XML, not an MTConnectDevices document (including
+ // an MTConnectError-under-200, whose own errorCode/text ex.Message already carries), or
+ // a document with no devices. Never includes the request URI.
+ return new(false, $"Agent at {safeUri} did not return a valid MTConnect device model: {ex.Message}", null);
+ }
+ catch (ArgumentException)
+ {
+ // Defensive: MTConnectAgentClient's own AgentUri validation, reached only if this
+ // method's own Uri.TryCreate above passed but the client's stricter check disagreed.
+ // Never forward the exception's message verbatim — it can echo the raw (possibly
+ // credentialed) config string.
+ return new(false, "Config's agentUri was rejected by the MTConnect client as invalid.", null);
+ }
+ catch (Exception ex)
+ {
+ // Last-resort catch for an exception type none of the above enumerate. Deliberately does
+ // NOT forward ex.Message: unlike the typed catches above (each individually audited for
+ // whether its message can carry the raw AgentUri/userinfo), an unenumerated type has no
+ // such audit, so ex.Message is an open door for a credential leak. safeUri + the
+ // exception's TYPE name is enough for an operator to act on without that risk.
+ return new(false, $"Probe against {safeUri} failed unexpectedly ({ex.GetType().Name}).", null);
+ }
+ }
+
+ ///
+ /// Builds a display form of for use in returned messages, composed
+ /// ONLY from scheme/host/port/path — never — so a credentialed
+ /// AgentUri can never leak a password into the AdminUI.
+ ///
+ private static string RedactedDisplay(Uri uri)
+ {
+ var portSegment = uri.IsDefaultPort ? string.Empty : $":{uri.Port}";
+
+ return $"{uri.Scheme}://{uri.Host}{portSegment}{uri.AbsolutePath}";
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDtos.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDtos.cs
new file mode 100644
index 00000000..740a2d71
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDtos.cs
@@ -0,0 +1,193 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
+
+///
+/// Result of an Agent /probe request: the full device hierarchy the Agent exposes.
+/// Produced by . This is the neutral shape both
+/// the TrakHound-backed implementation and a hand-rolled XML fallback must produce — nothing
+/// downstream of ever sees a TrakHound type or an
+/// XElement.
+///
+/// Every device the Agent's probe response declares.
+public sealed record MTConnectProbeModel(IReadOnlyList Devices);
+
+///
+/// Common shape shared by and :
+/// each may directly own data items and/or nest further components, to arbitrary depth. Backs
+/// the recursive walk.
+///
+public interface IMTConnectComponentContainer
+{
+ /// Components nested directly under this device/component (may be empty).
+ IReadOnlyList Components { get; }
+
+ /// Data items declared directly on this device/component (may be empty).
+ IReadOnlyList DataItems { get; }
+}
+
+///
+/// One MTConnect device from the Agent's probe response — the root of a nested
+/// component/data-item tree.
+///
+/// The device's id attribute.
+/// The device's name attribute, when the Agent supplies one.
+/// Child components declared directly under this device.
+/// Data items declared directly on this device (outside any component).
+public sealed record MTConnectDevice(
+ string Id,
+ string? Name,
+ IReadOnlyList Components,
+ IReadOnlyList DataItems) : IMTConnectComponentContainer;
+
+///
+/// One MTConnect component (e.g. Axes, Controller, Path) from the Agent's
+/// probe response. Components nest to arbitrary depth — a component may itself contain further
+/// child components as well as data items.
+///
+/// The component's id attribute.
+/// The component's name attribute, when the Agent supplies one.
+/// Child components nested directly under this component.
+/// Data items declared directly on this component.
+public sealed record MTConnectComponent(
+ string Id,
+ string? Name,
+ IReadOnlyList Components,
+ IReadOnlyList DataItems) : IMTConnectComponentContainer;
+
+///
+/// One MTConnect DataItem declaration from the Agent's probe response. This is pure metadata —
+/// it carries no observed value; values arrive separately via
+/// /
+/// and are correlated back to a data item by .
+///
+///
+/// The DataItem's id attribute — globally unique within the Agent's probe response and
+/// the correlation key used against .
+///
+/// The DataItem's name attribute, when the Agent supplies one.
+/// The DataItem's category attribute — SAMPLE, EVENT, or CONDITION.
+/// The DataItem's type attribute (e.g. POSITION, EXECUTION).
+/// The DataItem's subType attribute (e.g. ACTUAL, COMMANDED), when present.
+/// The DataItem's units attribute (e.g. MILLIMETER, REVOLUTION/MINUTE), when present.
+///
+/// The DataItem's representation attribute (e.g. TIME_SERIES, DISCRETE);
+/// absent means the MTConnect default (VALUE).
+///
+///
+/// The DataItem's sampleCount attribute — element count for a TIME_SERIES
+/// representation. Present only on data items that carry one.
+///
+public sealed record MTConnectDataItem(
+ string Id,
+ string? Name,
+ string Category,
+ string Type,
+ string? SubType,
+ string? Units,
+ string? Representation,
+ int? SampleCount);
+
+///
+/// Result of an Agent /current request, or of a single multipart chunk from a
+/// /sample stream. Produced by and
+/// .
+///
+///
+/// The Agent's current instance id (its MTConnectStreams header instanceId). Changes
+/// whenever the Agent restarts or its underlying device model changes; the driver watches this
+/// for change to raise rediscovery rather than trusting a stale observation snapshot.
+///
+///
+/// The header nextSequence — the sequence number to resume a subsequent
+/// call from.
+///
+///
+/// The header firstSequence — the oldest sequence number still held in the Agent's
+/// circular buffer. Load-bearing for sequence-gap detection: a caller that requested
+/// from a sequence older than this chunk's has fallen out of
+/// the Agent's buffer and must re-baseline via .
+///
+///
+/// The observations carried in this snapshot/chunk, in Agent-supplied order. Never null;
+/// empty when the chunk carries no new observations (e.g. a heartbeat).
+///
+public sealed record MTConnectStreamsResult(
+ long InstanceId,
+ long NextSequence,
+ long FirstSequence,
+ IReadOnlyList Observations);
+
+///
+/// One observed value for a single DataItem, as reported in a /current snapshot or a
+/// /sample stream chunk. Deliberately dumb: is carried as the raw
+/// string the Agent reported (including the literal UNAVAILABLE) with no interpretation
+/// applied here — coercing UNAVAILABLE to a bad-quality status and converting the string
+/// to the tag's DriverDataType are the observation index's job, not this layer's.
+///
+///
+/// The reporting DataItem's id — correlates back to
+/// from the probe model.
+///
+///
+/// The observed value exactly as the Agent reported it, or the literal string
+/// "UNAVAILABLE" when the Agent has no current value for the data item. Never
+/// pre-parsed into a nullable or an enum at this layer.
+///
+///
+/// The observation's Agent-reported timestamp, normalized to UTC (
+/// is always ). Becomes the OPC UA variable's SourceTimestamp.
+///
+///
+/// true when the Agent carried this observation's real content in child elements
+/// rather than as text — an MTConnect 2.0 DATA_SET / TABLE observation, whose
+/// content is a list of <Entry key="…"> elements.
+///
+/// Why the flag exists, and why only the parser can set it. is
+/// the element's concatenated descendant text, so a two-entry data set reads as the single
+/// token "12" — keys discarded, values run together. The observation index cannot
+/// detect that after the fact: neither this record nor the tag definition carries the
+/// DataItem's representation, and no value-shape heuristic can work, because
+/// concatenated entries are indistinguishable from a legitimate space-bearing EVENT such as
+/// a Message reading "Coolant level low". The one reliable discriminator —
+/// that the observation element had element children — exists only while the XML is still
+/// XML.
+///
+///
+/// Deliberately a neutral fact about the wire shape, not a status: mapping it to a
+/// quality code is the observation index's job (it codes these
+/// BadNotSupported rather than publishing concatenated noise as Good). Defaults to
+/// false, the shape of every ordinary scalar observation.
+///
+///
+public sealed record MTConnectObservation(
+ string DataItemId,
+ string Value,
+ DateTime TimestampUtc,
+ bool IsStructured = false);
+
+///
+/// Recursive helpers over the device/component tree.
+///
+public static class MTConnectComponentTreeExtensions
+{
+ ///
+ /// Enumerates every data item owned by this device or component, plus every data item owned
+ /// by any component nested beneath it, to arbitrary depth. Order is depth-first: a node's
+ /// own data items first, then each child component's data items in turn.
+ ///
+ /// The device or component to walk.
+ public static IEnumerable AllDataItems(this IMTConnectComponentContainer container)
+ {
+ foreach (var dataItem in container.DataItems)
+ {
+ yield return dataItem;
+ }
+
+ foreach (var child in container.Components)
+ {
+ foreach (var dataItem in child.AllDataItems())
+ {
+ yield return dataItem;
+ }
+ }
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectObservationIndex.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectObservationIndex.cs
new file mode 100644
index 00000000..c68a870c
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectObservationIndex.cs
@@ -0,0 +1,415 @@
+using System.Collections.Concurrent;
+using System.Collections.Frozen;
+using System.Globalization;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
+
+///
+/// The driver's live dataItemId → map: the single place
+/// an Agent observation's weakly-typed text becomes an OPC UA-shaped value + quality +
+/// timestamp. Written by the /current baseline read and by the /sample long-poll
+/// pump; read by IReadable.ReadAsync and the subscription fan-out.
+///
+///
+///
+/// Quality mapping (design §3.3). MTConnect has exactly one way to say "I have no
+/// value": the literal token UNAVAILABLE. It arrives as a Sample/Event's text, and —
+/// after normalizes the <Unavailable/>
+/// element name — as a CONDITION's value on that same exact spelling. It maps to
+/// BadNoCommunication with a null value: the Agent is reachable, the machine's
+/// data item is not. The match is ordinal and case-sensitive against the one spelling
+/// the parser guarantees; a case-insensitive match would swallow a legitimate free-text
+/// EVENT whose value happens to read "Unavailable".
+///
+///
+/// Failure posture. This type sits under IReadable and must never throw across
+/// that boundary on account of observation content. Every unusable observation degrades to a
+/// Bad-coded snapshot that still carries the Agent's source timestamp, so an operator can
+/// always see when the driver last heard about the tag even when it cannot show
+/// what. The only exceptions raised are for a null
+/// argument — a programming error, not data.
+///
+///
+/// Status codes are the canonical Opc.Ua.StatusCodes numeric values, declared
+/// once each below. Note that is 0x80740000: the
+/// 0x80730000 that four sibling drivers declare under that name is really
+/// BadWriteNotSupported, which would be actively misleading on a read path and
+/// renders as bare "Bad" in the driver CLI's SnapshotFormatter.
+///
+///
+/// Thread safety. Backed by a and
+/// guaranteed per-key atomic — nothing more, deliberately. writes
+/// each dataItemId exactly once per call with a single whole-snapshot assignment, so a
+/// reader can never observe a torn snapshot (a Good code beside a stale value). It is
+/// explicitly not a whole-batch atomic swap: OPC UA reads are per-tag and the driver
+/// offers no cross-tag consistency contract, /sample chunks are deltas whose
+/// interleaving is indistinguishable from two adjacent legal chunks, and a whole-index lock
+/// would serialize the pump against every read for no semantic gain.
+///
+///
+/// Shapes this build does not represent are coded BadNotSupported with a null
+/// value rather than approximated: a TIME_SERIES vector (array materialization is
+/// deferred to P1.5) and a DATA_SET / TABLE observation. The latter carries its
+/// real content in <Entry key=…> child elements, which reach a text-valued
+/// observation concatenated with the keys discarded — a two-entry set reads as the single
+/// token "12" — and because the type inference correctly demotes those
+/// representations to , coercion would otherwise
+/// "succeed" into a Good-coded snapshot carrying noise. This index cannot detect that shape
+/// itself (no value heuristic can separate concatenated entries from a legitimate
+/// space-bearing EVENT such as a Message reading "Coolant level low"), so it relies on
+/// , which
+/// sets while the XML is still XML.
+///
+///
+internal sealed class MTConnectObservationIndex
+{
+ ///
+ /// The one token that means "the Agent has no value for this data item". Matched ordinally:
+ /// the parser already normalizes a CONDITION's <Unavailable/> element name onto
+ /// this exact spelling, so every category lands on one comparison.
+ ///
+ private const string UnavailableSentinel = "UNAVAILABLE";
+
+ private const uint Good = 0x00000000u;
+
+ /// The Agent reported UNAVAILABLE, or supplied no text at all.
+ private const uint BadNoCommunication = 0x80310000u;
+
+ /// The tag is authored but the Agent has not yet reported it.
+ private const uint BadWaitingForInitialData = 0x80320000u;
+
+ /// The dataItemId is neither authored nor ever observed.
+ private const uint BadNodeIdUnknown = 0x80340000u;
+
+ /// The Agent reported a number the authored type cannot represent.
+ private const uint BadOutOfRange = 0x803C0000u;
+
+ /// The observation's shape is real data this build cannot represent (TIME_SERIES vectors).
+ private const uint BadNotSupported = 0x803D0000u;
+
+ /// The Agent reported text that is not a value of the authored type at all.
+ private const uint BadTypeMismatch = 0x80740000u;
+
+ ///
+ /// MTConnect's CONDITION state vocabulary, ranked by severity. Used only to reconcile
+ /// several states reported for one dataItemId within a single document — see
+ /// . An active Fault outranks UNAVAILABLE because a
+ /// fault is information and an absent value is not. Case-insensitive so a vendor's casing
+ /// of a state element still ranks; the parser itself emits the canonical spellings.
+ ///
+ private static readonly FrozenDictionary ConditionSeverity =
+ new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ [UnavailableSentinel] = 0,
+ ["Normal"] = 1,
+ ["Warning"] = 2,
+ ["Fault"] = 3,
+ }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
+
+ private readonly ConcurrentDictionary _snapshots = new(StringComparer.Ordinal);
+ private readonly FrozenDictionary _tags;
+ private readonly TimeProvider _timeProvider;
+
+ ///
+ /// Builds an index over the driver instance's authored tags.
+ ///
+ ///
+ /// The authored tags, keyed by (the DataItem
+ /// id) — normally . null or empty is
+ /// legal and means "no authored types": every observation is then indexed as its raw text
+ /// under .
+ ///
+ /// Nothing enforces FullName uniqueness at the options layer, so a duplicate is
+ /// operator-authorable. The last definition wins rather than throwing: refusing to
+ /// construct would turn an authoring slip into a driver that will not start, whereas
+ /// last-wins is deterministic and costs only that one tag's coercion type.
+ ///
+ ///
+ /// Clock behind ServerTimestampUtc; defaults to .
+ public MTConnectObservationIndex(
+ IEnumerable? tags = null,
+ TimeProvider? timeProvider = null)
+ {
+ _timeProvider = timeProvider ?? TimeProvider.System;
+
+ var map = new Dictionary(StringComparer.Ordinal);
+ foreach (var tag in tags ?? [])
+ {
+ if (tag is null || string.IsNullOrWhiteSpace(tag.FullName)) continue;
+
+ map[tag.FullName] = tag;
+ }
+
+ _tags = map.ToFrozenDictionary(StringComparer.Ordinal);
+ }
+
+ /// The number of distinct dataItemIds currently indexed.
+ public int Count => _snapshots.Count;
+
+ ///
+ /// Folds a /current snapshot or one /sample chunk into the index. Observations
+ /// with no authored tag are indexed too — the Agent streams the whole device, so they are
+ /// normal, and dropping them would make a streaming-but-not-yet-authored data item
+ /// indistinguishable from one that does not exist.
+ ///
+ /// The parsed streams document. An observation-free result is a legal
+ /// keep-alive and leaves the index untouched.
+ /// is null.
+ public void Apply(MTConnectStreamsResult result)
+ {
+ ArgumentNullException.ThrowIfNull(result);
+
+ if (result.Observations is not { Count: > 0 }) return;
+
+ // Duplicates are reconciled on the RAW observations first, so each dataItemId is written
+ // exactly once below as a single atomic whole-snapshot assignment. Doing it the other way
+ // — writing every observation and merging snapshots in place — would need a read-modify-write
+ // per key and could expose a half-merged state to a concurrent reader.
+ var resolved = new Dictionary(StringComparer.Ordinal);
+ foreach (var observation in result.Observations)
+ {
+ if (observation is null || string.IsNullOrWhiteSpace(observation.DataItemId)) continue;
+
+ resolved[observation.DataItemId] =
+ resolved.TryGetValue(observation.DataItemId, out var incumbent)
+ ? Reconcile(incumbent, observation)
+ : observation;
+ }
+
+ foreach (var (dataItemId, observation) in resolved)
+ {
+ _snapshots[dataItemId] = BuildSnapshot(observation);
+ }
+ }
+
+ ///
+ /// Returns the current snapshot for a DataItem. Never null and never throws: an
+ /// unknown or not-yet-reported id yields a Bad-coded snapshot rather than an error.
+ ///
+ /// The DataItem id the tag binds.
+ ///
+ /// The indexed snapshot; else BadWaitingForInitialData when the id is authored but
+ /// the Agent has not reported it yet (the standard OPC UA "subscribed, no value yet"
+ /// state); else BadNodeIdUnknown. The two are kept distinct because collapsing them
+ /// would tell an operator their correctly-authored tag does not exist.
+ ///
+ public DataValueSnapshot Get(string dataItemId)
+ {
+ if (!string.IsNullOrWhiteSpace(dataItemId) &&
+ _snapshots.TryGetValue(dataItemId, out var snapshot))
+ {
+ return snapshot;
+ }
+
+ var status = !string.IsNullOrWhiteSpace(dataItemId) && _tags.ContainsKey(dataItemId)
+ ? BadWaitingForInitialData
+ : BadNodeIdUnknown;
+
+ return new DataValueSnapshot(null, status, null, Now);
+ }
+
+ ///
+ /// Drops every indexed observation. The Agent-restart re-baseline: when the streams header's
+ /// instanceId changes, every value the index holds describes a device model that no
+ /// longer exists, and serving it would be worse than serving nothing.
+ ///
+ public void Clear() => _snapshots.Clear();
+
+ private DateTime Now => _timeProvider.GetUtcNow().UtcDateTime;
+
+ ///
+ /// Picks the winner when one dataItemId appears more than once in a single document. A
+ /// <Condition> container legitimately carries several simultaneously active
+ /// states (a Fault and a Warning at once), so for a pair of recognized condition state words
+ /// the most severe wins — plain last-wins would under-report an active Fault as a
+ /// Warning purely because of element order. Anything else is last-wins, matching the Agent's
+ /// ascending-sequence ordering.
+ ///
+ /// Deliberately scoped to one document: a later is the Agent's new
+ /// statement of the state, so a cleared fault must fall back to Normal. A worst-of that
+ /// spanned Applies would latch every fault forever.
+ ///
+ ///
+ private static MTConnectObservation Reconcile(MTConnectObservation incumbent, MTConnectObservation challenger)
+ {
+ if (ConditionSeverity.TryGetValue(incumbent.Value ?? string.Empty, out var incumbentSeverity) &&
+ ConditionSeverity.TryGetValue(challenger.Value ?? string.Empty, out var challengerSeverity))
+ {
+ return challengerSeverity >= incumbentSeverity ? challenger : incumbent;
+ }
+
+ return challenger;
+ }
+
+ private DataValueSnapshot BuildSnapshot(MTConnectObservation observation)
+ {
+ var serverTimestamp = Now;
+ var sourceTimestamp = DateTime.SpecifyKind(observation.TimestampUtc, DateTimeKind.Utc);
+
+ // No text at all is the degenerate spelling of "no value": MTConnect defines UNAVAILABLE as
+ // the only way to report absence, so an empty element is not an empty string value.
+ if (string.IsNullOrWhiteSpace(observation.Value) ||
+ string.Equals(observation.Value, UnavailableSentinel, StringComparison.Ordinal))
+ {
+ return new DataValueSnapshot(null, BadNoCommunication, sourceTimestamp, serverTimestamp);
+ }
+
+ // DATA_SET / TABLE: the Agent carried the content in child elements, so
+ // Value is their concatenated text with the keys discarded (a two-entry set reads "12").
+ // Publishing that as a Good string would be noise an operator would trust. Checked before
+ // the tag lookup because it is a fact about the wire shape, true whether or not the data
+ // item is authored — and after the sentinel, so an unavailable data set still reports the
+ // truthful no-comms.
+ if (observation.IsStructured)
+ {
+ return new DataValueSnapshot(null, BadNotSupported, sourceTimestamp, serverTimestamp);
+ }
+
+ _tags.TryGetValue(observation.DataItemId, out var tag);
+
+ // TIME_SERIES vectors arrive as one space-separated string. P1 does not materialize OPC UA
+ // arrays (deferred to P1.5), and the honest answer is "real data I cannot represent" — not a
+ // type mismatch, and emphatically not the first element parsed out and served as Good.
+ // Checked AFTER the sentinel so an unavailable array tag still reports the truthful no-comms.
+ if (tag is { IsArray: true })
+ {
+ return new DataValueSnapshot(null, BadNotSupported, sourceTimestamp, serverTimestamp);
+ }
+
+ // An unauthored observation has no declared target type; String is the only coercion that
+ // cannot fail, and it carries the Agent's text with no interpretation applied.
+ var status = Coerce(observation.Value, tag?.DriverDataType ?? DriverDataType.String, out var value);
+
+ return status == Good
+ ? new DataValueSnapshot(value, Good, sourceTimestamp, serverTimestamp)
+ : new DataValueSnapshot(null, status, sourceTimestamp, serverTimestamp);
+ }
+
+ ///
+ /// Converts the Agent's text to the tag's authored type. Every parse is
+ /// : MTConnect is an invariant-format wire, and a
+ /// host running a comma-decimal culture would otherwise read 123.4567 as
+ /// 1234567 — a silently wrong value under Good quality, the worst possible outcome.
+ ///
+ /// on success; otherwise a Bad code and a null value.
+ private static uint Coerce(string raw, DriverDataType type, out object? value)
+ {
+ value = null;
+ var text = raw.Trim();
+
+ switch (type)
+ {
+ // Reference is a Galaxy-style attribute reference encoded as an OPC UA String. It is
+ // never a sensible authoring for MTConnect, but degrading it to text is harmless where
+ // rejecting it would break a tag for no protection.
+ case DriverDataType.String:
+ case DriverDataType.Reference:
+ value = raw;
+ return Good;
+
+ case DriverDataType.Boolean:
+ if (bool.TryParse(text, out var flag)) { value = flag; return Good; }
+ if (text is "1") { value = true; return Good; }
+ if (text is "0") { value = false; return Good; }
+ return BadTypeMismatch;
+
+ case DriverDataType.DateTime:
+ if (DateTime.TryParse(
+ text,
+ CultureInfo.InvariantCulture,
+ DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal,
+ out var moment))
+ {
+ value = DateTime.SpecifyKind(moment, DateTimeKind.Utc);
+ return Good;
+ }
+
+ return BadTypeMismatch;
+
+ case DriverDataType.Float32:
+ if (float.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var single))
+ {
+ value = single;
+ return Good;
+ }
+
+ return BadTypeMismatch;
+
+ case DriverDataType.Float64:
+ if (double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var real))
+ {
+ value = real;
+ return Good;
+ }
+
+ return BadTypeMismatch;
+
+ case DriverDataType.Int16:
+ if (short.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i16))
+ {
+ value = i16;
+ return Good;
+ }
+
+ break;
+
+ case DriverDataType.Int32:
+ if (int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i32))
+ {
+ value = i32;
+ return Good;
+ }
+
+ break;
+
+ case DriverDataType.Int64:
+ if (long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var i64))
+ {
+ value = i64;
+ return Good;
+ }
+
+ break;
+
+ case DriverDataType.UInt16:
+ if (ushort.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var u16))
+ {
+ value = u16;
+ return Good;
+ }
+
+ break;
+
+ case DriverDataType.UInt32:
+ if (uint.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var u32))
+ {
+ value = u32;
+ return Good;
+ }
+
+ break;
+
+ case DriverDataType.UInt64:
+ if (ulong.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var u64))
+ {
+ value = u64;
+ return Good;
+ }
+
+ break;
+
+ default:
+ // A DriverDataType this driver has no rule for. Text always round-trips, so serving
+ // it beats failing a tag over an enum member added later.
+ value = raw;
+ return Good;
+ }
+
+ // Integer parse failed. Distinguishing the two causes is worth one extra parse: it tells an
+ // operator whether to retype the tag or widen it.
+ return double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out _)
+ ? BadOutOfRange // a number the authored type cannot represent (overflow, or a fraction)
+ : BadTypeMismatch; // not a number at all
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectProbeParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectProbeParser.cs
new file mode 100644
index 00000000..6d2d7c51
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectProbeParser.cs
@@ -0,0 +1,189 @@
+using System.Xml;
+using System.Xml.Linq;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
+
+///
+/// Turns an MTConnect Agent /probe response (an MTConnectDevices XML document)
+/// into the neutral . Deliberately a pure, socket-free static
+/// so the whole parse is unit-testable against canned fixtures.
+///
+///
+///
+/// Why hand-rolled rather than TrakHound. The TrakHound packages Task 0 pinned
+/// (MTConnect.NET-Common + -HTTP 6.9.0.2 — both dropped in Task 7) contain no
+/// IResponseDocumentFormatter implementation — the XML formatter ships in the
+/// separate, unreferenced MTConnect.NET-XML package — so
+/// ResponseDocumentFormatter.CreateDevicesResponseDocument("xml", stream) answers
+/// Success = false / "Document Formatter Not found for "xml"" (verified by
+/// reflection + live invocation against this repo's own fixture, 2026-07-24). TrakHound also
+/// offers no socket-free public parse entry point, which the plan requires.
+///
+///
+/// Three shapes of the wire format this parser is built around.
+/// (1) A device may declare data items directly on <Device><DataItems>,
+/// outside any component — a walker that only recurses <Components> silently
+/// drops them.
+/// (2) The children of <Components> are named for the component type
+/// (<Controller>, <Path>, <Axes>, …) — there is no
+/// literal <Component> element — so every element child of
+/// <Components> is a component.
+/// (3) The document is XML-namespaced and the namespace URI carries the MTConnect version
+/// (urn:mtconnect.org:MTConnectDevices:1.3 … :2.0). Element matching and
+/// attribute reading therefore go through , whose two rules
+/// (match on local name, read attributes unqualified) are shared verbatim with
+/// — see that type for why each rule exists.
+///
+///
+/// One deliberate divergence from the streams parser: no BOM/whitespace trim here.
+/// A /probe body arrives whole from HttpContent, so it begins exactly where
+/// the Agent's document begins. A /sample chunk, by contrast, is lifted out of a
+/// multipart frame and can carry a byte-order mark or the framing's own line break ahead of
+/// the XML declaration, which XmlReader rejects — hence the trim there and not here.
+/// This asymmetry is intentional, not an oversight.
+///
+///
+/// Failure posture. Anything that is not a well-formed MTConnect device model throws
+/// . It never degrades to an empty-but-successful model:
+/// an empty device model that looks successful is precisely the defect class that has bitten
+/// this codebase before (#485), and here it would tear the driver's browse tree down to
+/// nothing while reporting healthy.
+///
+///
+internal static class MTConnectProbeParser
+{
+ ///
+ /// Bound on component nesting depth. Real device models nest a handful of levels; this only
+ /// exists so a pathological or hostile document cannot recurse the parser into a
+ /// process-fatal (uncatchable) stack overflow.
+ ///
+ private const int MaxComponentDepth = 64;
+
+ /// How 's shared error messages name this document.
+ private const string Subject = "/probe response";
+
+ /// Parses a /probe response held as a string.
+ /// The raw MTConnectDevices XML document.
+ ///
+ /// The payload is empty, not well-formed XML, not an MTConnectDevices document (an
+ /// Agent MTConnectError document included — Agents answer a bad device path with one
+ /// under HTTP 200), or declares no usable device.
+ ///
+ public static MTConnectProbeModel Parse(string xml)
+ {
+ if (string.IsNullOrWhiteSpace(xml))
+ {
+ throw new InvalidDataException("MTConnect /probe response was empty; expected an MTConnectDevices XML document.");
+ }
+
+ XDocument document;
+ try
+ {
+ document = XDocument.Parse(xml);
+ }
+ catch (XmlException ex)
+ {
+ throw new InvalidDataException($"MTConnect /probe response is not well-formed XML: {ex.Message}", ex);
+ }
+
+ return Build(document);
+ }
+
+ /// Parses a /probe response read from a stream.
+ /// A stream positioned at the start of the XML document.
+ /// As for .
+ public static MTConnectProbeModel Parse(Stream stream)
+ {
+ ArgumentNullException.ThrowIfNull(stream);
+
+ XDocument document;
+ try
+ {
+ document = XDocument.Load(stream);
+ }
+ catch (XmlException ex)
+ {
+ throw new InvalidDataException($"MTConnect /probe response is not well-formed XML: {ex.Message}", ex);
+ }
+
+ return Build(document);
+ }
+
+ private static MTConnectProbeModel Build(XDocument document)
+ {
+ var root = document.Root
+ ?? throw new InvalidDataException("MTConnect /probe response has no root element.");
+
+ if (!MTConnectXml.IsNamed(root, "MTConnectDevices"))
+ {
+ throw new InvalidDataException(MTConnectXml.DescribeUnexpectedRoot(root, "MTConnectDevices", Subject));
+ }
+
+ var devicesContainer = MTConnectXml.ChildrenNamed(root, "Devices").FirstOrDefault()
+ ?? throw new InvalidDataException(
+ "MTConnect /probe response has no element; it does not describe a device model.");
+
+ var devices = devicesContainer.Elements().Select(ReadDevice).ToList();
+ if (devices.Count == 0)
+ {
+ throw new InvalidDataException(
+ "MTConnect /probe response declares no devices; refusing to report an empty device model as a successful probe.");
+ }
+
+ return new MTConnectProbeModel(devices);
+ }
+
+ private static MTConnectDevice ReadDevice(XElement element) =>
+ new(
+ MTConnectXml.RequiredAttribute(element, "id", "Device", Subject),
+ MTConnectXml.OptionalAttribute(element, "name"),
+ ReadComponents(element, depth: 1),
+ ReadDataItems(element));
+
+ private static MTConnectComponent ReadComponent(XElement element, int depth) =>
+ new(
+ MTConnectXml.RequiredAttribute(element, "id", $"Component <{element.Name.LocalName}>", Subject),
+ MTConnectXml.OptionalAttribute(element, "name"),
+ ReadComponents(element, depth + 1),
+ ReadDataItems(element));
+
+ ///
+ /// Every element child of this container's <Components> element, whatever it is
+ /// named — the element name is the component type, not the literal string "Component".
+ ///
+ private static IReadOnlyList ReadComponents(XElement container, int depth)
+ {
+ if (depth > MaxComponentDepth)
+ {
+ throw new InvalidDataException(
+ $"MTConnect /probe response nests components more than {MaxComponentDepth} levels deep; refusing to recurse further.");
+ }
+
+ return MTConnectXml.ChildrenNamed(container, "Components")
+ .SelectMany(components => components.Elements())
+ .Select(element => ReadComponent(element, depth))
+ .ToList();
+ }
+
+ private static IReadOnlyList ReadDataItems(XElement container) =>
+ MTConnectXml.ChildrenNamed(container, "DataItems")
+ .SelectMany(dataItems => MTConnectXml.ChildrenNamed(dataItems, "DataItem"))
+ .Select(ReadDataItem)
+ .ToList();
+
+ private static MTConnectDataItem ReadDataItem(XElement element)
+ {
+ var id = MTConnectXml.RequiredAttribute(element, "id", "DataItem", Subject);
+
+ return new MTConnectDataItem(
+ id,
+ MTConnectXml.OptionalAttribute(element, "name"),
+ MTConnectXml.RequiredAttribute(element, "category", $"DataItem '{id}'", Subject),
+ MTConnectXml.RequiredAttribute(element, "type", $"DataItem '{id}'", Subject),
+ MTConnectXml.OptionalAttribute(element, "subType"),
+ MTConnectXml.OptionalAttribute(element, "units"),
+ MTConnectXml.OptionalAttribute(element, "representation"),
+ MTConnectXml.OptionalIntAttribute(element, "sampleCount", $"DataItem '{id}'", Subject));
+ }
+
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectSampleHandle.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectSampleHandle.cs
new file mode 100644
index 00000000..ee0119ed
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectSampleHandle.cs
@@ -0,0 +1,29 @@
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
+
+///
+/// Driver-internal identity of one /sample subscription, matching the sibling drivers'
+/// shape (Galaxy's GalaxySubscriptionHandle, the shared PollGroupEngine's
+/// PollSubscriptionHandle): a monotonic per-driver id plus a diagnostic string that
+/// carries it into logs.
+///
+///
+///
+/// Every handle shares ONE Agent stream. Unlike a polled driver, where each
+/// subscription owns a loop, MTConnect's /sample long poll is per-Agent: the driver
+/// opens exactly one and fans each chunk out to whichever handles subscribe the reporting
+/// DataItem. The handle is therefore purely an identity — it owns no connection, no task,
+/// and no cursor — and dropping one only stops the stream when it was the last.
+///
+///
+/// A for value equality on the id, so a handle that has round-tripped
+/// through the caller still resolves. The id is never reused within a driver instance.
+///
+///
+/// The monotonic per-driver subscription id.
+internal sealed record MTConnectSampleHandle(long SubscriptionId) : ISubscriptionHandle
+{
+ ///
+ public string DiagnosticId => $"mtconnect-sub-{SubscriptionId}";
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectStreamExceptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectStreamExceptions.cs
new file mode 100644
index 00000000..362dc16f
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectStreamExceptions.cs
@@ -0,0 +1,161 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
+
+///
+/// Base type for every way an Agent /sample long poll can stop delivering chunks for a
+/// reason that is not the caller cancelling it.
+///
+///
+///
+/// Why these are exceptions rather than a normal end of enumeration.
+/// 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
+/// stopped" indistinguishable from "the data path is idle", which is the
+/// quiet-successful-looking-termination shape that produced this repo's #485 defect. Every
+/// non-cancellation end is therefore loud, typed, and carries the Agent URI.
+///
+///
+/// Catch this base type to handle "the stream is over" uniformly; catch the two derived
+/// types to distinguish a transient end (the Agent closed a connection — reconnect)
+/// from a configuration error (the Agent will never stream to this request — retrying
+/// is pointless until an operator changes something).
+///
+///
+public abstract class MTConnectStreamException : Exception
+{
+ /// Creates the exception with no message.
+ protected MTConnectStreamException()
+ {
+ }
+
+ /// Creates the exception with a message.
+ /// The operator-facing description.
+ protected MTConnectStreamException(string message)
+ : base(message)
+ {
+ }
+
+ /// Creates the exception with a message and an inner cause.
+ /// The operator-facing description.
+ /// The underlying cause.
+ protected MTConnectStreamException(string message, Exception innerException)
+ : base(message, innerException)
+ {
+ }
+}
+
+/// How an Agent /sample stream ended.
+public enum MTConnectStreamEndReason
+{
+ ///
+ /// The transport closed mid-stream — the connection dropped, the Agent restarted, or a proxy
+ /// timed the connection out. Ordinary and transient: reconnect from the last
+ /// .
+ ///
+ ConnectionClosed = 0,
+
+ ///
+ /// The Agent wrote the closing --boundary-- delimiter, ending the multipart response
+ /// deliberately. Usually means the request was bounded (an Agent honouring a count
+ /// that exhausted, or an administrative stop) rather than a fault. Also reconnectable, but
+ /// worth distinguishing in logs: a stream that keeps closing cleanly points at the request's
+ /// parameters, not at the network.
+ ///
+ ClosingBoundary = 1
+}
+
+///
+/// The Agent's /sample stream ended without the caller cancelling it. See
+/// for why this is not a normal end of enumeration.
+///
+public sealed class MTConnectStreamEndedException : MTConnectStreamException
+{
+ /// Creates the exception for a stream that ended for .
+ /// How the stream ended.
+ /// The /sample request URI whose stream ended.
+ /// How many chunks were yielded before the end.
+ public MTConnectStreamEndedException(MTConnectStreamEndReason reason, string agentUri, long chunksDelivered)
+ : base($"MTConnect /sample stream from '{agentUri}' ended after {chunksDelivered} chunk(s) without the caller cancelling it ({Describe(reason)}). The stream is contracted to run until cancelled, so this is reported rather than returned; resume from the last chunk's nextSequence.")
+ {
+ Reason = reason;
+ AgentUri = agentUri;
+ ChunksDelivered = chunksDelivered;
+ }
+
+ /// Creates the exception with a message.
+ /// The operator-facing description.
+ public MTConnectStreamEndedException(string message)
+ : base(message)
+ {
+ AgentUri = string.Empty;
+ }
+
+ /// Creates the exception with a message and an inner cause.
+ /// The operator-facing description.
+ /// The underlying cause.
+ public MTConnectStreamEndedException(string message, Exception innerException)
+ : base(message, innerException)
+ {
+ AgentUri = string.Empty;
+ }
+
+ /// Creates the exception with no message.
+ public MTConnectStreamEndedException()
+ {
+ AgentUri = string.Empty;
+ }
+
+ /// How the stream ended.
+ public MTConnectStreamEndReason Reason { get; }
+
+ /// The /sample request URI whose stream ended.
+ public string AgentUri { get; }
+
+ /// How many chunks were yielded before the stream ended.
+ public long ChunksDelivered { get; }
+
+ private static string Describe(MTConnectStreamEndReason reason) =>
+ reason switch
+ {
+ MTConnectStreamEndReason.ClosingBoundary => "the Agent wrote the closing multipart boundary",
+ _ => "the connection closed mid-stream"
+ };
+}
+
+///
+/// The Agent answered a /sample request with something that cannot be consumed as a
+/// stream at all — a single non-multipart document, or a multipart/* response with no
+/// boundary parameter to frame it by.
+///
+///
+/// This is a configuration error, not a transient one, and is deliberately typed apart
+/// from : reconnecting will produce the identical
+/// response forever. The usual cause is an endpoint that is not an MTConnect Agent (a reverse
+/// proxy, a load balancer's health page, or an Agent fronted by something that buffers and
+/// collapses multipart/x-mixed-replace). Reported instead of yielding the one document
+/// the Agent did return, because a single snapshot followed by a healthy-looking finished
+/// stream is exactly how a dead data path disguises itself as a working one.
+///
+public sealed class MTConnectStreamNotSupportedException : MTConnectStreamException
+{
+ /// Creates the exception with a message.
+ /// The operator-facing description.
+ public MTConnectStreamNotSupportedException(string message)
+ : base(message)
+ {
+ }
+
+ /// Creates the exception with a message and an inner cause.
+ /// The operator-facing description.
+ /// The underlying cause.
+ public MTConnectStreamNotSupportedException(string message, Exception innerException)
+ : base(message, innerException)
+ {
+ }
+
+ /// Creates the exception with no message.
+ public MTConnectStreamNotSupportedException()
+ {
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectStreamsParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectStreamsParser.cs
new file mode 100644
index 00000000..20f0b642
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectStreamsParser.cs
@@ -0,0 +1,240 @@
+using System.Globalization;
+using System.Xml;
+using System.Xml.Linq;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
+
+///
+/// Turns an MTConnect Agent MTConnectStreams document — a /current snapshot or one
+/// chunk of a /sample multipart stream — into the neutral
+/// . Deliberately a pure, socket-free static so the whole
+/// parse is unit-testable against canned fixtures; chunk framing lives in
+/// and hands this parser one chunk's XML at a time.
+///
+///
+///
+/// Why hand-rolled rather than TrakHound. Same finding as
+/// : the packages pinned by Task 0 ship no XML document
+/// formatter, and TrakHound's streaming client offers no socket-free entry point. Both
+/// package references were dropped in Task 7; the driver depends only on the BCL.
+///
+///
+/// The Streams document is shaped nothing like the Devices document, and three
+/// differences drive this parser's design.
+///
+///
+///
+///
+/// The observation element's name is the data item's type in PascalCase
+/// (Position, PartCount, Execution) — not the probe's
+/// UPPER_SNAKE POSITION / PART_COUNT. The standard defines hundreds of
+/// types and vendors add more, so matching a fixed set of element names would drop
+/// observations silently. Every element child of <Samples>,
+/// <Events> and <Condition> is therefore an observation,
+/// keyed by its dataItemId attribute — the only identity the probe model and
+/// the streams document actually share.
+///
+///
+///
+///
+/// A CONDITION observation's value is its ELEMENT NAME, not its text.
+/// <Normal/> / <Warning/> / <Fault/> /
+/// <Unavailable/> carry the state in the name and are typically empty;
+/// where text is present it is the operator-facing message, not the state. Reading
+/// the text would report every condition as an empty string.
+/// <Unavailable/> is normalized to the literal
+/// so a no-comms condition lands on exactly the
+/// same sentinel as a Sample/Event whose text is UNAVAILABLE — the
+/// observation index maps that one token to bad quality.
+///
+///
+///
+///
+/// Timestamps are normalized to UTC explicitly. MTConnect timestamps are
+/// ISO-8601 Zulu, but a bare DateTime.Parse yields Local or
+/// Unspecified depending on machine settings — and this value becomes the
+/// OPC UA SourceTimestamp, so a wrong shifts every
+/// timestamp by the host's UTC offset with nothing to show for it.
+///
+///
+///
+///
+/// Failure posture. A malformed document, a non-MTConnectStreams root (an Agent
+/// MTConnectError served under HTTP 200 included), an unusable header, or an
+/// observation missing its dataItemId/timestamp all throw
+/// . A document with a valid header and zero
+/// observations, by contrast, is entirely legal — /sample chunks are deltas and an
+/// idle Agent's keep-alive is an observation-free document — so it parses to an empty
+/// observation list rather than throwing.
+///
+///
+internal static class MTConnectStreamsParser
+{
+ ///
+ /// The single token that means "the Agent has no value for this data item". Sample/Event
+ /// observations carry it as text; a CONDITION carries it as the element name
+ /// <Unavailable/> and is normalized onto this spelling here.
+ ///
+ private const string UnavailableSentinel = "UNAVAILABLE";
+
+ private const string ConditionContainer = "Condition";
+
+ /// How 's shared error messages name this document.
+ private const string Subject = "streams response";
+
+ /// How those messages name the header element.
+ private const string HeaderOwner = "the ";
+
+ ///
+ /// The three elements whose element children are observations. A ComponentStream may carry
+ /// any subset of them, including none.
+ ///
+ private static readonly string[] ObservationContainers = ["Samples", "Events", ConditionContainer];
+
+ /// Parses a /current response, or one /sample chunk, held as a string.
+ /// The raw MTConnectStreams XML document.
+ ///
+ /// The payload is empty, not well-formed XML, not an MTConnectStreams document (an
+ /// Agent MTConnectError included — Agents answer a bad request with one under HTTP
+ /// 200), carries no usable <Header>, or carries a malformed observation.
+ ///
+ public static MTConnectStreamsResult Parse(string xml)
+ {
+ if (string.IsNullOrWhiteSpace(xml))
+ {
+ throw new InvalidDataException(
+ "MTConnect streams response was empty; expected an MTConnectStreams XML document.");
+ }
+
+ XDocument document;
+ try
+ {
+ // A chunk lifted out of a multipart frame can carry a byte-order mark or the framing's
+ // trailing line break; XmlReader rejects either ahead of the XML declaration.
+ document = XDocument.Parse(xml.Trim('\uFEFF', ' ', '\t', '\r', '\n'));
+ }
+ catch (XmlException ex)
+ {
+ throw new InvalidDataException($"MTConnect streams response is not well-formed XML: {ex.Message}", ex);
+ }
+
+ return Build(document);
+ }
+
+ /// Parses a /current response read from a stream.
+ /// A stream positioned at the start of the XML document.
+ /// As for .
+ public static MTConnectStreamsResult Parse(Stream stream)
+ {
+ ArgumentNullException.ThrowIfNull(stream);
+
+ XDocument document;
+ try
+ {
+ document = XDocument.Load(stream);
+ }
+ catch (XmlException ex)
+ {
+ throw new InvalidDataException($"MTConnect streams response is not well-formed XML: {ex.Message}", ex);
+ }
+
+ return Build(document);
+ }
+
+ private static MTConnectStreamsResult Build(XDocument document)
+ {
+ var root = document.Root
+ ?? throw new InvalidDataException("MTConnect streams response has no root element.");
+
+ if (!MTConnectXml.IsNamed(root, "MTConnectStreams"))
+ {
+ throw new InvalidDataException(MTConnectXml.DescribeUnexpectedRoot(root, "MTConnectStreams", Subject));
+ }
+
+ var header = MTConnectXml.ChildrenNamed(root, "Header").FirstOrDefault()
+ ?? throw new InvalidDataException(
+ "MTConnect streams response has no ; without it the sequence bookkeeping the sample pump runs on is unknowable.");
+
+ return new MTConnectStreamsResult(
+ MTConnectXml.RequiredLongAttribute(header, "instanceId", HeaderOwner, Subject),
+ MTConnectXml.RequiredLongAttribute(header, "nextSequence", HeaderOwner, Subject),
+ MTConnectXml.RequiredLongAttribute(header, "firstSequence", HeaderOwner, Subject),
+ ReadObservations(root));
+ }
+
+ ///
+ /// Collects every observation in the document, in Agent-supplied order. Containers are found
+ /// by descending the whole <Streams> subtree rather than by walking a fixed
+ /// DeviceStream → ComponentStream chain, so a vendor's extra nesting level cannot silently
+ /// hide a device's observations.
+ ///
+ private static IReadOnlyList ReadObservations(XElement root)
+ {
+ var observations = new List();
+
+ foreach (var container in root.Descendants().Where(IsObservationContainer))
+ {
+ var isCondition = MTConnectXml.IsNamed(container, ConditionContainer);
+ foreach (var element in container.Elements())
+ {
+ observations.Add(ReadObservation(element, isCondition));
+ }
+ }
+
+ return observations;
+ }
+
+ private static MTConnectObservation ReadObservation(XElement element, bool isCondition)
+ {
+ var dataItemId = MTConnectXml.RequiredAttribute(element, "dataItemId", $"Observation <{element.Name.LocalName}>", Subject);
+
+ return new MTConnectObservation(
+ dataItemId,
+ isCondition ? ConditionState(element) : element.Value.Trim(),
+ ReadTimestampUtc(element, dataItemId),
+
+ // A DATA_SET/TABLE observation keeps its content in children, so the
+ // text read above concatenates them into nonsense ("12" for two entries). Flagged here
+ // because this is the only layer that can still see the distinction — see
+ // MTConnectObservation.IsStructured. Never true for a CONDITION: its value comes from
+ // the element NAME, so child content cannot corrupt it, and flagging one would throw
+ // away a perfectly well-determined state.
+ IsStructured: !isCondition && element.HasElements);
+ }
+
+ ///
+ /// A condition's state is its element name. Unavailable is normalized to the
+ /// spelling so downstream quality mapping has exactly one
+ /// token to recognize; every other state is reported as-named (Normal, Warning,
+ /// Fault, and any vendor state).
+ ///
+ private static string ConditionState(XElement element)
+ {
+ var state = element.Name.LocalName;
+
+ return string.Equals(state, "Unavailable", StringComparison.OrdinalIgnoreCase) ? UnavailableSentinel : state;
+ }
+
+ private static DateTime ReadTimestampUtc(XElement element, string dataItemId)
+ {
+ var raw = MTConnectXml.RequiredAttribute(element, "timestamp", $"Observation '{dataItemId}'", Subject);
+
+ if (!DateTime.TryParse(
+ raw,
+ CultureInfo.InvariantCulture,
+ DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal,
+ out var parsed))
+ {
+ throw new InvalidDataException(
+ $"MTConnect streams response is malformed: observation '{dataItemId}' has an unparseable 'timestamp' ('{raw}').");
+ }
+
+ // AdjustToUniversal already yields Utc; stated explicitly so a future styles change cannot
+ // quietly hand the OPC UA layer a Local or Unspecified SourceTimestamp.
+ return DateTime.SpecifyKind(parsed, DateTimeKind.Utc);
+ }
+
+ private static bool IsObservationContainer(XElement element) =>
+ ObservationContainers.Any(name => MTConnectXml.IsNamed(element, name));
+
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectXml.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectXml.cs
new file mode 100644
index 00000000..63c31262
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectXml.cs
@@ -0,0 +1,146 @@
+using System.Globalization;
+using System.Xml.Linq;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
+
+///
+/// The element/attribute reading rules shared by and
+/// . Both documents are the same dialect of XML and must be
+/// read by the same rules; keeping one copy is what stops the two parsers drifting apart on the
+/// two rules below, either of which fails silently rather than loudly.
+///
+///
+///
+/// Rule 1: elements are matched on , ignoring the
+/// namespace. The namespace URI carries the MTConnect version
+/// (urn:mtconnect.org:MTConnectDevices:1.3 … :2.0), so a fully-qualified match
+/// would hard-code a version. It also silently drops vendor-extension elements declared in
+/// their own namespace, whose standard children still inherit the default MTConnect
+/// namespace — the browse tree simply comes back short, with no error anywhere.
+///
+///
+/// Rule 2: attributes are read unqualified. A prefixed attribute
+/// (xsi:type on a document root, a vendor's x:dataItemId) must never be
+/// mistaken for the real type / dataItemId, which would invent a data item the
+/// probe never declared.
+///
+///
+internal static class MTConnectXml
+{
+ /// Does have the given local name, whatever its namespace?
+ /// The element to test.
+ /// The unqualified name to match.
+ public static bool IsNamed(XElement element, string localName) =>
+ string.Equals(element.Name.LocalName, localName, StringComparison.Ordinal);
+
+ /// Direct element children of with the given local name.
+ /// The element whose children to filter.
+ /// The unqualified name to match.
+ public static IEnumerable ChildrenNamed(XElement parent, string localName) =>
+ parent.Elements().Where(child => IsNamed(child, localName));
+
+ ///
+ /// Reads an unqualified attribute, normalizing a present-but-empty value to null.
+ /// See the type-level remarks for why only unqualified attributes are considered.
+ ///
+ /// The element carrying the attribute.
+ /// The unqualified attribute name.
+ public static string? OptionalAttribute(XElement element, string name)
+ {
+ var value = element.Attribute(name)?.Value;
+
+ return string.IsNullOrWhiteSpace(value) ? null : value;
+ }
+
+ /// Reads a required unqualified attribute, or throws naming the owner and the attribute.
+ /// The element carrying the attribute.
+ /// The unqualified attribute name.
+ /// How to describe the element in the error message.
+ /// How to describe the document in the error message (e.g. "/probe response").
+ /// The attribute is absent or blank.
+ public static string RequiredAttribute(XElement element, string name, string owner, string subject)
+ {
+ var value = OptionalAttribute(element, name);
+
+ return value ?? throw new InvalidDataException(
+ $"MTConnect {subject} is malformed: {owner} has no '{name}' attribute.");
+ }
+
+ /// Reads an optional unqualified attribute as an .
+ /// The element carrying the attribute.
+ /// The unqualified attribute name.
+ /// How to describe the element in the error message.
+ /// How to describe the document in the error message.
+ /// The attribute is present but not an integer.
+ public static int? OptionalIntAttribute(XElement element, string name, string owner, string subject)
+ {
+ var raw = OptionalAttribute(element, name);
+ if (raw is null)
+ {
+ return null;
+ }
+
+ if (!int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value))
+ {
+ throw new InvalidDataException(
+ $"MTConnect {subject} is malformed: {owner} has a non-numeric '{name}' attribute ('{raw}').");
+ }
+
+ return value;
+ }
+
+ /// Reads a required unqualified attribute as a .
+ /// The element carrying the attribute.
+ /// The unqualified attribute name.
+ /// How to describe the element in the error message.
+ /// How to describe the document in the error message.
+ /// The attribute is absent, blank, or not an integer.
+ public static long RequiredLongAttribute(XElement element, string name, string owner, string subject)
+ {
+ var raw = RequiredAttribute(element, name, owner, subject);
+
+ if (!long.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value))
+ {
+ throw new InvalidDataException(
+ $"MTConnect {subject} is malformed: {owner} has a non-numeric '{name}' attribute ('{raw}').");
+ }
+
+ return value;
+ }
+
+ ///
+ /// Builds the error message for a document whose root is not what was expected, lifting the
+ /// Agent's own error text when the response is an MTConnectError document — which
+ /// Agents return under HTTP 200, so EnsureSuccessStatusCode never fires and
+ /// this is the only place the operator can learn what the Agent objected to.
+ ///
+ /// The document's actual root element.
+ /// The root element name the caller required.
+ /// How to describe the document in the error message.
+ public static string DescribeUnexpectedRoot(XElement root, string expectedRootName, string subject)
+ {
+ var message =
+ $"MTConnect {subject} root element is <{root.Name.LocalName}>, expected <{expectedRootName}>.";
+
+ if (!IsNamed(root, "MTConnectError"))
+ {
+ return message;
+ }
+
+ var errors = root.Descendants()
+ .Where(e => IsNamed(e, "Error"))
+ .Select(e =>
+ {
+ var code = OptionalAttribute(e, "errorCode");
+ var text = e.Value.Trim();
+
+ return code is null ? text : $"{code}: {text}";
+ })
+ .Where(text => text.Length > 0)
+ .ToList();
+
+ return errors.Count == 0
+ ? message
+ : $"{message} The Agent reported: {string.Join("; ", errors)}";
+ }
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.csproj
new file mode 100644
index 00000000..c1b6b054
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.csproj
@@ -0,0 +1,31 @@
+
+
+
+ net10.0
+ enable
+ enable
+ latest
+ true
+ true
+ $(NoWarn);CS1591
+ ZB.MOM.WW.OtOpcUa.Driver.MTConnect
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor
index 7990462a..5fa88406 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/DriverConfigModal.razor
@@ -63,6 +63,9 @@
case DriverTypeNames.Mqtt:
break;
+ case DriverTypeNames.MTConnect:
+
+ break;
default:
No typed config form for driver type @_driverType.
break;
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/MTConnectDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/MTConnectDriverForm.razor
new file mode 100644
index 00000000..1bbd9674
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/MTConnectDriverForm.razor
@@ -0,0 +1,406 @@
+@* Embeddable MTConnect Agent driver config form body. Hosted by DriverConfigModal (/raw). The Agent's
+ base URI is a DRIVER-level setting, not a per-device endpoint: the driver appends the standard Agent
+ paths (/probe, /current, /sample) to it and optionally narrows to one DeviceName, so unlike Modbus/S7
+ there is no host/port to split onto the device.
+
+ MTConnect is read-only — there are no write knobs to author. *@
+@using System.Text.Json
+@using System.Text.Json.Nodes
+@using System.Text.Json.Serialization
+@using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers
+
+@* Agent *@
+
+
MTConnect Agent
+
+
+
+
+
+
Required. The driver appends /probe, /current and /sample to this base.
+
+
+
+
+
Narrows requests to {base}/{device}/… on a multi-device Agent.
+
+
+
+
+
+@* Polling *@
+
+
Polling
+
+
+
+
+
+
Default 5000. Must be > 0.
+
+
+
+
+
Default 1000. Must be > 0 — a 0 busy-loops the stream.
+
+
+
+
+
Default 1000 observations per chunk. Must be > 0.
+
+
+
+
+
Default 10000. Must be > 0 — a 0 makes a quiet stream indistinguishable from a dead one.
+
+
+
+
+
+@* Connectivity probe *@
+
+
Connectivity probe
+
+
+
+
+
+
+
+
Periodic /probe driving the Running/Stopped host status.
+
+
+
+
+
Default 5000. Must be > 0 while probing is on.
+
+
+
+
+
Default 2000. Must be > 0 while probing is on.
+
+
+
+
+
+@* Reconnect *@
+
+
Reconnect backoff
+
+
+
+
+
+
Default 0 (retry immediately).
+
+
+
+
+
Default 30000.
+
+
+
+
+
Default 2.0 (doubles each retry).
+
+
+
+
+
+@if (_form.Validate() is { } error)
+{
+
@error
+}
+
+
+
+@code {
+ /// The driver's DriverConfig JSON (supports @bind-DriverConfigJson).
+ [Parameter] public string DriverConfigJson { get; set; } = "{}";
+ /// Two-way config callback.
+ [Parameter] public EventCallback DriverConfigJsonChanged { get; set; }
+ /// The driver's resilience-policy JSON, or null.
+ [Parameter] public string? ResilienceConfig { get; set; }
+ /// Two-way resilience callback.
+ [Parameter] public EventCallback ResilienceConfigChanged { get; set; }
+
+ // Case-insensitive read so a PascalCase seeded/hand-authored config loads its real values instead of
+ // silently showing defaults; camelCase write to match the driver's own DTO spelling. The string-enum
+ // converter is mandatory across the whole *DriverForm fleet (DriverPageJsonConverterTests) — the
+ // MTConnect config carries no enum today, and the guard is precisely what stops the first one added
+ // later from serialising as a number the string-typed factory cannot parse.
+ private static readonly JsonSerializerOptions _jsonOpts = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ PropertyNameCaseInsensitive = true,
+ UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
+ WriteIndented = false,
+ Converters = { new JsonStringEnumConverter() },
+ };
+
+ private MTConnectFormModel _form = new();
+ private string? _lastParsed;
+
+ protected override void OnParametersSet()
+ {
+ if (!string.Equals(_lastParsed, DriverConfigJson, StringComparison.Ordinal))
+ {
+ _form = MTConnectFormModel.FromJson(DriverConfigJson);
+ _lastParsed = DriverConfigJson;
+ }
+ }
+
+ /// Serialises the current form state over the config document it was loaded from.
+ /// The driver's DriverConfig JSON.
+ public string GetConfigJson() => BuildConfigJson(_form, DriverConfigJson);
+
+ ///
+ /// Projects onto , returning the driver's
+ /// DriverConfig JSON. Pure and static so the whole decision surface is unit-testable —
+ /// this project has no bUnit, so anything left in markup is verified only by the live gate.
+ ///
+ ///
+ ///
+ /// Unknown TOP-LEVEL keys in are preserved (notably the
+ /// driver's hand-authored tags[] surface, which this form does not expose and must not
+ /// silently delete). The probe and reconnect sub-objects are replaced wholesale
+ /// — the form owns every field in both.
+ ///
+ ///
+ /// A key whose form value is unusable (blank URI, non-positive timing knob) is REMOVED rather
+ /// than written: ParseOptions then substitutes the driver's own positive default
+ /// instead of the driver faulting at Initialize on RequirePositive (arch-review
+ /// 01/S-6). Removing rather than merely skipping matters — a stale positive value already in
+ /// the document would otherwise survive and disagree with what the form shows.
+ ///
+ ///
+ /// The form state to serialise.
+ /// The config document being edited, or null when creating.
+ /// The driver's DriverConfig JSON.
+ public static string BuildConfigJson(MTConnectFormModel form, string? existingJson)
+ {
+ ArgumentNullException.ThrowIfNull(form);
+
+ var bag = ParseObject(existingJson) ?? new JsonObject();
+ var emitted = JsonNode.Parse(JsonSerializer.Serialize(form.ToDto(), _jsonOpts))!.AsObject();
+
+ foreach (var key in emitted.Select(p => p.Key).ToList())
+ {
+ var value = emitted[key];
+ emitted.Remove(key); // detach before re-parenting into the bag
+
+ if (value is null)
+ {
+ bag.Remove(key);
+ }
+ else
+ {
+ // A nulled member of a sub-object must be an ABSENT key, not "key": null. The driver's
+ // nullable DTO binds both to "use the default", but an explicit null in a stored config
+ // reads as a deliberately-cleared setting to anyone inspecting it.
+ if (value is JsonObject nested) { StripNulls(nested); }
+ bag[key] = value;
+ }
+ }
+
+ return bag.ToJsonString();
+ }
+
+ private static void StripNulls(JsonObject o)
+ {
+ foreach (var key in o.Where(p => p.Value is null).Select(p => p.Key).ToList())
+ {
+ o.Remove(key);
+ }
+ }
+
+ private async Task EmitAsync()
+ {
+ var json = GetConfigJson();
+ _lastParsed = json;
+ await DriverConfigJsonChanged.InvokeAsync(json);
+ }
+
+ private async Task OnResilienceChanged(string? r)
+ {
+ ResilienceConfig = r;
+ await ResilienceConfigChanged.InvokeAsync(r);
+ }
+
+ private static JsonObject? ParseObject(string? json)
+ {
+ if (string.IsNullOrWhiteSpace(json)) { return null; }
+ try { return JsonNode.Parse(json) as JsonObject; }
+ catch (JsonException) { return null; }
+ }
+
+ ///
+ /// Mutable mirror of the driver's DriverConfig document. Deliberately NOT
+ /// MTConnectDriverOptions: that type models the parsed options (TimeSpan probe knobs,
+ /// materialised tag lists), and serialising it would emit "probe":{"interval":"00:00:05"}
+ /// — which the driver's integer intervalMs DTO cannot bind.
+ ///
+ public sealed class MTConnectFormModel
+ {
+ /// The Agent's base URI. Required by the driver.
+ public string AgentUri { get; set; } = "";
+ /// Optional single-device scope; blank = agent-wide.
+ public string? DeviceName { get; set; }
+ /// Per-call HTTP deadline in ms. Must be > 0.
+ public int RequestTimeoutMs { get; set; } = 5_000;
+ /// The /sampleinterval query parameter in ms. Must be > 0.
+ public int SampleIntervalMs { get; set; } = 1_000;
+ /// The /samplecount query parameter. Must be > 0.
+ public int SampleCount { get; set; } = 1_000;
+ /// The /sampleheartbeat query parameter in ms. Must be > 0.
+ public int HeartbeatMs { get; set; } = 10_000;
+ /// Whether the background connectivity probe runs.
+ public bool ProbeEnabled { get; set; } = true;
+ /// Probe cadence in ms. Must be > 0 while .
+ public int ProbeIntervalMs { get; set; } = 5_000;
+ /// Probe request deadline in ms. Must be > 0 while .
+ public int ProbeTimeoutMs { get; set; } = 2_000;
+ /// Delay before the first reconnect attempt in ms; 0 = immediate, and legal.
+ public int ReconnectMinBackoffMs { get; set; }
+ /// Upper bound on the geometric reconnect backoff, in ms.
+ public int ReconnectMaxBackoffMs { get; set; } = 30_000;
+ /// Multiplier applied to the reconnect backoff each retry.
+ public double ReconnectBackoffMultiplier { get; set; } = 2.0;
+
+ /// Loads a form model from a DriverConfig JSON document, falling back to the driver's
+ /// own defaults for anything absent or unusable.
+ /// The DriverConfig JSON, or null/blank/malformed for a fresh form.
+ /// The populated form model.
+ public static MTConnectFormModel FromJson(string? json)
+ {
+ var dto = TryDeserialize(json);
+ var defaults = new MTConnectFormModel();
+ if (dto is null) { return defaults; }
+
+ return new MTConnectFormModel
+ {
+ AgentUri = dto.AgentUri ?? "",
+ DeviceName = dto.DeviceName,
+ RequestTimeoutMs = dto.RequestTimeoutMs ?? defaults.RequestTimeoutMs,
+ SampleIntervalMs = dto.SampleIntervalMs ?? defaults.SampleIntervalMs,
+ SampleCount = dto.SampleCount ?? defaults.SampleCount,
+ HeartbeatMs = dto.HeartbeatMs ?? defaults.HeartbeatMs,
+ ProbeEnabled = dto.Probe?.Enabled ?? defaults.ProbeEnabled,
+ ProbeIntervalMs = dto.Probe?.IntervalMs ?? defaults.ProbeIntervalMs,
+ ProbeTimeoutMs = dto.Probe?.TimeoutMs ?? defaults.ProbeTimeoutMs,
+ ReconnectMinBackoffMs = dto.Reconnect?.MinBackoffMs ?? defaults.ReconnectMinBackoffMs,
+ ReconnectMaxBackoffMs = dto.Reconnect?.MaxBackoffMs ?? defaults.ReconnectMaxBackoffMs,
+ ReconnectBackoffMultiplier = dto.Reconnect?.BackoffMultiplier ?? defaults.ReconnectBackoffMultiplier,
+ };
+ }
+
+ /// Projects the form onto the driver's wire DTO, nulling any value the driver would
+ /// reject so the key is omitted and the driver's own default applies.
+ /// The wire DTO.
+ public MTConnectConfigDto ToDto() => new()
+ {
+ AgentUri = Trimmed(AgentUri),
+ DeviceName = Trimmed(DeviceName),
+ RequestTimeoutMs = Positive(RequestTimeoutMs),
+ SampleIntervalMs = Positive(SampleIntervalMs),
+ SampleCount = Positive(SampleCount),
+ HeartbeatMs = Positive(HeartbeatMs),
+ Probe = new MTConnectProbeConfigDto
+ {
+ Enabled = ProbeEnabled,
+ IntervalMs = Positive(ProbeIntervalMs),
+ TimeoutMs = Positive(ProbeTimeoutMs),
+ },
+ Reconnect = new MTConnectReconnectConfigDto
+ {
+ MinBackoffMs = ReconnectMinBackoffMs >= 0 ? ReconnectMinBackoffMs : null,
+ MaxBackoffMs = Positive(ReconnectMaxBackoffMs),
+ BackoffMultiplier = ReconnectBackoffMultiplier > 0 ? ReconnectBackoffMultiplier : null,
+ },
+ };
+
+ /// Returns an operator-facing message describing why this config would not start, or
+ /// null when it is usable. Advisory: already prevents an unusable value
+ /// from reaching the driver, so this exists to explain the snap-back rather than to gate save.
+ /// The error message, or null when valid.
+ public string? Validate()
+ {
+ if (string.IsNullOrWhiteSpace(AgentUri))
+ {
+ return "An Agent base URI is required — the driver cannot start without it.";
+ }
+
+ var bad = new List();
+ if (RequestTimeoutMs <= 0) { bad.Add("Request timeout"); }
+ if (SampleIntervalMs <= 0) { bad.Add("Sample interval"); }
+ if (SampleCount <= 0) { bad.Add("Sample count"); }
+ if (HeartbeatMs <= 0) { bad.Add("Heartbeat"); }
+ if (ProbeEnabled && ProbeIntervalMs <= 0) { bad.Add("Probe interval"); }
+ if (ProbeEnabled && ProbeTimeoutMs <= 0) { bad.Add("Probe timeout"); }
+ if (ReconnectMaxBackoffMs <= 0) { bad.Add("Max backoff"); }
+
+ return bad.Count == 0
+ ? null
+ : $"Must be greater than zero: {string.Join(", ", bad)}. A non-positive value is dropped on save and the driver's default applies.";
+ }
+
+ private static int? Positive(int value) => value > 0 ? value : null;
+
+ private static string? Trimmed(string? value)
+ => string.IsNullOrWhiteSpace(value) ? null : value.Trim();
+
+ private static MTConnectConfigDto? TryDeserialize(string? json)
+ {
+ if (string.IsNullOrWhiteSpace(json)) { return null; }
+ try { return JsonSerializer.Deserialize(json, _jsonOpts); }
+ catch (JsonException) { return null; }
+ }
+ }
+
+ /// Mirror of the driver's private MTConnectDriverConfigDto — the exact shape
+ /// MTConnectDriver.ParseOptions binds. Every member is nullable so an omitted key means "use
+ /// the driver's default" rather than "reset to zero".
+ public sealed class MTConnectConfigDto
+ {
+ /// The Agent's base URI.
+ public string? AgentUri { get; init; }
+ /// Optional single-device scope.
+ public string? DeviceName { get; init; }
+ /// Per-call HTTP deadline in ms.
+ public int? RequestTimeoutMs { get; init; }
+ /// The /sampleinterval query parameter in ms.
+ public int? SampleIntervalMs { get; init; }
+ /// The /samplecount query parameter.
+ public int? SampleCount { get; init; }
+ /// The /sampleheartbeat query parameter in ms.
+ public int? HeartbeatMs { get; init; }
+ /// Background connectivity-probe knobs.
+ public MTConnectProbeConfigDto? Probe { get; init; }
+ /// Reconnect backoff knobs.
+ public MTConnectReconnectConfigDto? Reconnect { get; init; }
+ }
+
+ /// Wire mirror of the driver's probe config block.
+ public sealed class MTConnectProbeConfigDto
+ {
+ /// Whether probing runs.
+ public bool? Enabled { get; init; }
+ /// Probe cadence in ms.
+ public int? IntervalMs { get; init; }
+ /// Probe request deadline in ms.
+ public int? TimeoutMs { get; init; }
+ }
+
+ /// Wire mirror of the driver's reconnect config block.
+ public sealed class MTConnectReconnectConfigDto
+ {
+ /// Delay before the first reconnect attempt, in ms.
+ public int? MinBackoffMs { get; init; }
+ /// Upper bound on the geometric backoff, in ms.
+ public int? MaxBackoffMs { get; init; }
+ /// Multiplier applied each retry.
+ public double? BackoffMultiplier { get; init; }
+ }
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor
index 3ddc867f..510a97b1 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Raw/RawDriverTypeDialog.razor
@@ -70,7 +70,8 @@
("MQTT", DriverTypeNames.Mqtt),
("Galaxy", DriverTypeNames.Galaxy),
("Sql", DriverTypeNames.Sql),
- ("Calculation", "Calculation"),
+ ("Calculation", DriverTypeNames.Calculation),
+ ("MTConnect", DriverTypeNames.MTConnect),
];
private string _type = DriverTypeNames.Modbus;
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/MTConnectTagConfigEditor.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/MTConnectTagConfigEditor.razor
new file mode 100644
index 00000000..7a71f951
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/TagEditors/MTConnectTagConfigEditor.razor
@@ -0,0 +1,118 @@
+@* Typed tag-config editor for an MTConnect Agent tag. A thin shell over MTConnectTagConfigModel — every
+ rule (defaults, unknown-key preservation, the numeric-dataType trap) lives in that model, not here.
+
+ MTConnect is READ-ONLY (the driver is deliberately not IWritable), so unlike Modbus there is no
+ "Writable" toggle to author.
+
+ The mtCategory/mtType/mtSubType/units row is /probe-sourced device-model metadata: rendered disabled +
+ readonly with NO binding of any kind, so nothing an operator can do in this UI writes to it. The model
+ still round-trips the values so a browse-committed stamp survives an edit of the data type.
+
+ NOTE: no inferred-type hint is shown. MTConnectDataTypeInference.Infer() also keys on the DataItem's
+ `representation` (DATA_SET/TABLE demote to String even on a SAMPLE; TIME_SERIES is honoured only on a
+ SAMPLE), and the tag-config model does not carry `representation`. Calling Infer() from here would
+ therefore agree with the browse picker for ordinary items and silently DISAGREE for exactly the
+ representation-driven ones — the failure that never shows up in the obvious test. The picker already
+ stamped the inferred type into `dataType` on commit; this editor is the override surface for it. *@
+@using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors
+@using ZB.MOM.WW.OtOpcUa.Core.Abstractions
+
+
+}
+
+@code {
+ /// The tag's raw TagConfig JSON (supports @bind-ConfigJson).
+ [Parameter] public string? ConfigJson { get; set; }
+ /// Two-way config callback.
+ [Parameter] public EventCallback ConfigJsonChanged { get; set; }
+ /// DriverType of the selected driver — supplied by the hosting modal for browse-capable pickers.
+ [Parameter] public string DriverType { get; set; } = "";
+ /// Live accessor for the selected driver's DriverConfig JSON (browse connect config).
+ [Parameter] public Func GetDriverConfigJson { get; set; } = () => "{}";
+
+ private MTConnectTagConfigModel _m = new();
+ private string? _lastConfigJson;
+
+ // Re-parse only when the incoming JSON actually changes, so an unrelated parent re-render
+ // (Blazor Server live-status pushes do this) can't reset the user's in-progress edits.
+ protected override void OnParametersSet()
+ {
+ if (ConfigJson == _lastConfigJson) { return; }
+ _lastConfigJson = ConfigJson;
+ _m = MTConnectTagConfigModel.FromJson(ConfigJson);
+ }
+
+ // Placeholder for absent probe metadata: an empty read-only box reads as "the field is broken".
+ private static string Display(string? value) => string.IsNullOrWhiteSpace(value) ? "—" : value;
+
+ // TryParse so a bad/empty change value can never throw into the Blazor circuit — it falls back.
+ private static TEnum ParseEnum(object? v, TEnum fallback) where TEnum : struct, Enum
+ => Enum.TryParse(v?.ToString(), out var r) ? r : fallback;
+
+ private async Task Update(Action apply)
+ {
+ apply();
+ await ConfigJsonChanged.InvokeAsync(_m.ToJson());
+ }
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawBrowseCommitMapper.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawBrowseCommitMapper.cs
index 47ef905d..49c48764 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawBrowseCommitMapper.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/RawBrowseCommitMapper.cs
@@ -132,6 +132,11 @@ public static class RawBrowseCommitMapper
return new FocasTagConfigModel { Address = address }.ToJson();
if (Is(driverType, DriverTypeNames.S7))
return new S7TagConfigModel { Address = address }.ToJson();
+ // MTConnect: the DataItem id. The driver also accepts "address"/"dataItemId", but the typed
+ // editor's canonical spelling is fullName — committing under the generic key below would open
+ // in that editor with an EMPTY id field and blank it on save.
+ if (Is(driverType, DriverTypeNames.MTConnect))
+ return new MTConnectTagConfigModel { FullName = address }.ToJson();
if (Is(driverType, DriverTypeNames.Galaxy))
return WriteSingleKey("attributeRef", address);
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/MTConnectTagConfigModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/MTConnectTagConfigModel.cs
new file mode 100644
index 00000000..6f6d8096
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/MTConnectTagConfigModel.cs
@@ -0,0 +1,163 @@
+using System.Text.Json.Nodes;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors;
+
+/// Typed working model for an MTConnect tag's TagConfig JSON. The tag binds a single Agent
+/// DataItem by its id attribute ( — MTConnect is read-only, so
+/// there is no address encoding beyond the id); the remaining fields are probe-sourced display
+/// metadata plus an author-settable type override. Preserves unrecognised JSON keys across a
+/// load→save.
+///
+///
+/// /// come
+/// from the Agent's /probe response (see MTConnectTagDefinition in the driver's
+/// Contracts project) and are shown read-only by Task 18's editor — this model still round-trips
+/// them so a browse-commit that stamped them survives a manual edit of .
+/// / are pure author context (which physical
+/// device/component this DataItem belongs to); nothing downstream consumes them.
+///
+///
+/// is an override of the driver's own probe-based inference
+/// (MTConnectDataTypeInference.Infer, which the browse-commit path already applied when it
+/// wrote and this field). It is deliberately NOT recomputed here — this
+/// editor only re-serialises whatever the operator picks or the browse commit stamped, never a
+/// locally-reinvented inference that could disagree with the one true rule.
+///
+///
+public sealed class MTConnectTagConfigModel
+{
+ /// The MTConnect DataItem's id attribute — the driver's read/subscribe key. Required.
+ /// Loaded from fullName, or from the driver's alternate spellings dataItemId /
+ /// address (see ); always saved as fullName.
+ public string FullName { get; set; } = "";
+
+ /// Logical data type override for the DataItem's value. Defaults to
+ /// — MTConnect is weakly typed on the wire, so an unset override should never coerce to a numeric type
+ /// that can fail to parse (mirrors MTConnectDataTypeInference's own String-leaning default).
+ public DriverDataType DataType { get; set; } = DriverDataType.String;
+
+ /// The DataItem's probe-sourced category (SAMPLE/EVENT/CONDITION). Read-only display metadata.
+ public string? MtCategory { get; set; }
+
+ /// The DataItem's probe-sourced type (e.g. POSITION, EXECUTION). Read-only display metadata.
+ public string? MtType { get; set; }
+
+ /// The DataItem's probe-sourced subType (e.g. ACTUAL, COMMANDED). Read-only display metadata.
+ public string? MtSubType { get; set; }
+
+ /// The DataItem's probe-sourced units (e.g. MILLIMETER). Read-only display metadata.
+ public string? Units { get; set; }
+
+ /// Author-entered note identifying the owning MTConnect device. Pure authoring context; not
+ /// consumed by the driver or the runtime.
+ public string? MtDevice { get; set; }
+
+ /// Author-entered note identifying the owning MTConnect component. Pure authoring context; not
+ /// consumed by the driver or the runtime.
+ public string? MtComponent { get; set; }
+
+ // The as-loaded raw "dataType" JSON value, kept only to detect the ParseEnum numeric-text trap in
+ // Validate() below — Enum.TryParse (which TagConfigJson.GetEnum uses) silently accepts a quoted
+ // number ("8" -> Float64) exactly as readily as a name, so a config that was ever hand-authored or
+ // migrated with a numeric dataType would otherwise load into a *valid-looking* enum value with no
+ // signal that it isn't a name. ToJson() always re-writes DataType as a name, so this can only ever
+ // be non-null on the FIRST load of an already-poisoned config, never after a save through this model.
+ private string? _rawDataType;
+
+ private JsonObject _bag = new();
+
+ ///
+ /// The accepted spellings of the DataItem id, in precedence order, mirroring the driver's own
+ /// MTConnectRawTagConfigDto: fullName (this model's canonical key),
+ /// dataItemId (what an operator hand-authoring the blob reaches for), and address
+ /// (what RawBrowseCommitMapper wrote for MTConnect before it grew a typed-editor branch).
+ /// Reading only fullName made every already-committed tag open with an EMPTY id field and
+ /// get blanked on save, while the data plane kept working — the symptom looked cosmetic.
+ /// normalises onto fullName and drops the aliases, so the two
+ /// spellings can never drift apart on a later edit.
+ ///
+ private static readonly string[] IdKeys = ["fullName", "dataItemId", "address"];
+
+ /// Loads a model from a TagConfig JSON string, defaulting any absent field and retaining
+ /// every original key (so fields this editor doesn't expose survive a load→save).
+ /// The raw TagConfig JSON string, or null for a new/empty config.
+ /// The populated .
+ public static MTConnectTagConfigModel FromJson(string? json)
+ {
+ var o = TagConfigJson.ParseOrNew(json);
+ return new MTConnectTagConfigModel
+ {
+ FullName = ReadId(o),
+ DataType = TagConfigJson.GetEnum(o, "dataType", DriverDataType.String),
+ MtCategory = TagConfigJson.GetString(o, "mtCategory"),
+ MtType = TagConfigJson.GetString(o, "mtType"),
+ MtSubType = TagConfigJson.GetString(o, "mtSubType"),
+ Units = TagConfigJson.GetString(o, "units"),
+ MtDevice = TagConfigJson.GetString(o, "mtDevice"),
+ MtComponent = TagConfigJson.GetString(o, "mtComponent"),
+ _rawDataType = TagConfigJson.GetString(o, "dataType"),
+ _bag = o,
+ };
+ }
+
+ /// Serialises this model back to a TagConfig JSON string over the preserved key bag.
+ /// dataType is always written as its enum NAME (never a bare number — see the
+ /// enum-serialization trap in the class remarks); the optional metadata keys are omitted when
+ /// blank rather than persisted as empty strings.
+ /// The serialised TagConfig JSON string.
+ public string ToJson()
+ {
+ // Normalise onto the canonical key: a blob loaded via an alias must not keep the alias, or the
+ // two spellings drift on the next edit and the driver silently reads the stale one.
+ TagConfigJson.Set(_bag, "dataItemId", null);
+ TagConfigJson.Set(_bag, "address", null);
+ TagConfigJson.Set(_bag, "fullName", FullName.Trim());
+ TagConfigJson.Set(_bag, "dataType", DataType);
+ TagConfigJson.Set(_bag, "mtCategory", Blank(MtCategory));
+ TagConfigJson.Set(_bag, "mtType", Blank(MtType));
+ TagConfigJson.Set(_bag, "mtSubType", Blank(MtSubType));
+ TagConfigJson.Set(_bag, "units", Blank(Units));
+ TagConfigJson.Set(_bag, "mtDevice", Blank(MtDevice));
+ TagConfigJson.Set(_bag, "mtComponent", Blank(MtComponent));
+ return TagConfigJson.Serialize(_bag);
+ }
+
+ /// Validation hook; returns an error message or null when the model is valid.
+ /// An error message describing the validation failure, or null when the model is valid.
+ public string? Validate()
+ {
+ if (string.IsNullOrWhiteSpace(FullName))
+ {
+ return "A DataItem id (fullName) is required.";
+ }
+
+ // See the _rawDataType remarks: a quoted number parses as a "valid" enum today but is never
+ // something this editor itself would have written, so surface it instead of silently accepting it.
+ if (_rawDataType is { Length: > 0 } raw && raw.All(char.IsAsciiDigit))
+ {
+ return $"dataType \"{raw}\" is a numeric value, not a named type — re-select a data type.";
+ }
+
+ return null;
+ }
+
+ // Normalises a blank/whitespace-only string to null so TagConfigJson.Set omits the key rather than
+ // persisting an empty string.
+ private static string? Blank(string? value) => string.IsNullOrWhiteSpace(value) ? null : value;
+
+ // First non-blank id spelling wins (see IdKeys). A present-but-blank "fullName" must not shadow a
+ // populated alias — that is exactly the state a save from the broken editor left behind.
+ private static string ReadId(JsonObject o)
+ {
+ foreach (var key in IdKeys)
+ {
+ if (Blank(TagConfigJson.GetString(o, key)) is { } id)
+ {
+ return id;
+ }
+ }
+
+ return "";
+ }
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs
index 98a81ba2..11adc332 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigEditorMap.cs
@@ -27,6 +27,7 @@ public static class TagConfigEditorMap
// asserts bidirectional parity). Task 11 repoints all Sql keys onto DriverTypeNames.Sql.
[SqlDriver.DriverTypeName] = typeof(Components.Shared.Uns.TagEditors.SqlTagConfigEditor),
[DriverTypeNames.Mqtt] = typeof(Components.Shared.Uns.TagEditors.MqttTagConfigEditor),
+ [DriverTypeNames.MTConnect] = typeof(Components.Shared.Uns.TagEditors.MTConnectTagConfigEditor),
};
/// Returns the editor component type for a driver type, or null if none is registered.
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs
index bd56b19d..863f1552 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/TagConfigValidator.cs
@@ -27,6 +27,7 @@ public static class TagConfigValidator
// Keyed off SqlDriver.DriverTypeName (= "Sql") — see the note in TagConfigEditorMap.
[SqlDriver.DriverTypeName] = j => SqlTagConfigModel.FromJson(j).Validate(),
[DriverTypeNames.Mqtt] = j => MqttTagConfigModel.FromJson(j).Validate(),
+ [DriverTypeNames.MTConnect] = j => MTConnectTagConfigModel.FromJson(j).Validate(),
};
///
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj
index 01e0e8b3..a81f6b25 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj
@@ -37,6 +37,7 @@
+
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/DiscoveryCapture.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/DiscoveryCapture.cs
new file mode 100644
index 00000000..d40b4662
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/DiscoveryCapture.cs
@@ -0,0 +1,110 @@
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests;
+
+///
+/// An that records the tree DiscoverAsync streams into
+/// it, so discovery against a live Agent can be asserted on shape (which leaf landed under which
+/// folder, with which driver-side metadata) rather than on a flat list.
+///
+///
+/// A near-twin of the MTConnect unit suite's CapturingBuilder, restated here rather than
+/// shared: that type is internal to a different test assembly, and the two production
+/// capturing builders (Commons.Browsing / Runtime.Drivers) would drag the server
+/// stack into a driver integration project that deliberately references only the driver.
+///
+internal sealed class DiscoveryCapture : IAddressSpaceBuilder
+{
+ private readonly State _state;
+ private readonly string _path;
+
+ /// Creates the root scope a driver's DiscoverAsync is handed.
+ public DiscoveryCapture()
+ {
+ _state = new State();
+ _path = string.Empty;
+ }
+
+ private DiscoveryCapture(State state, string path)
+ {
+ _state = state;
+ _path = path;
+ }
+
+ /// Every folder streamed, with the slash-joined path it landed at.
+ public IReadOnlyList Folders => _state.Folders;
+
+ /// Every variable streamed, with the folder path it landed under.
+ public IReadOnlyList Variables => _state.Variables;
+
+ ///
+ public IAddressSpaceBuilder Folder(string browseName, string displayName)
+ {
+ var path = _path.Length == 0 ? browseName : $"{_path}/{browseName}";
+ _state.Folders.Add(new CapturedFolder(path, _path, browseName, displayName));
+
+ return new DiscoveryCapture(_state, path);
+ }
+
+ ///
+ public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
+ {
+ var captured = new CapturedVariable(_path, browseName, displayName, attributeInfo);
+ _state.Variables.Add(captured);
+
+ return new Handle(captured);
+ }
+
+ ///
+ public void AddProperty(string browseName, DriverDataType dataType, object? value)
+ {
+ // Recorded nowhere: the MTConnect driver streams no node properties, and a capture that
+ // threw here would fail a driver that legitimately started to.
+ }
+
+ private sealed class State
+ {
+ public List Folders { get; } = [];
+
+ public List Variables { get; } = [];
+ }
+
+ private sealed class Handle(CapturedVariable variable) : IVariableHandle
+ {
+ public string FullReference => variable.Attr.FullName;
+
+ public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
+ {
+ variable.AlarmConditions.Add(info);
+
+ return new NullSink();
+ }
+
+ private sealed class NullSink : IAlarmConditionSink
+ {
+ public void OnTransition(AlarmEventArgs args)
+ {
+ // No test here drives an alarm transition through discovery.
+ }
+ }
+ }
+}
+
+/// One folder captured from a discovery stream.
+/// Slash-joined path of the folder itself.
+/// Slash-joined path of the scope it was added to (empty at the root).
+/// The browse name the driver supplied.
+/// The display name the driver supplied.
+internal sealed record CapturedFolder(string Path, string ParentPath, string BrowseName, string DisplayName);
+
+/// One variable captured from a discovery stream.
+/// Slash-joined path of the folder it landed under.
+/// The browse name the driver supplied.
+/// The display name the driver supplied.
+/// The driver-side attribute metadata the driver stamped on it.
+internal sealed record CapturedVariable(
+ string ParentPath, string BrowseName, string DisplayName, DriverAttributeInfo Attr)
+{
+ /// Alarm conditions the driver marked this variable with, if any.
+ public List AlarmConditions { get; } = [];
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/Devices.xml b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/Devices.xml
new file mode 100644
index 00000000..19979d1a
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/Devices.xml
@@ -0,0 +1,112 @@
+
+
+
+
+
+
+
+ MTConnect integration-test fixture
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md
new file mode 100644
index 00000000..d9fa1e8c
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/README.md
@@ -0,0 +1,105 @@
+# MTConnect integration-test fixture — the official C++ Agent + an SHDR data source
+
+The MTConnect C++ Agent (`mtconnect/agent`, pinned) serving a canned device model, fed live
+data by a standard-library SHDR adapter. No image build step: both services run stock images
+with this folder's files bind-mounted.
+
+> **The published image is `mtconnect/agent`, not `mtconnect/cppagent`.** `cppagent` is the
+> name of the source project on GitHub; there is no Docker Hub repository under that name.
+
+| File | Purpose |
+|---|---|
+| [`docker-compose.yml`](docker-compose.yml) | Two services: `agent` (published on :5000) and `adapter` (internal, :7878) |
+| [`agent.cfg`](agent.cfg) | Agent configuration, bind-mounted at `/mtconnect/config/agent.cfg` (the image's CMD path) |
+| [`Devices.xml`](Devices.xml) | The canned device model the Agent serves from `/probe` |
+| [`adapter.py`](adapter.py) | SHDR feeder — the data source. Pure stdlib, runs on a stock `python:*-alpine` |
+
+## Why there is an adapter service
+
+The `mtconnect/agent` image ships **only** the agent binary plus its schemas and styles — there
+is no bundled simulator. An Agent with no adapter answers `/probe` correctly and then reports
+**every observation `UNAVAILABLE` forever**, which cannot prove that reads return real values or
+that the `/sample` long poll delivers anything. `adapter.py` is what makes the fixture live.
+
+The Agent dials **out** to the adapter (see the `Adapters` block in `agent.cfg`); the adapter is
+not published to the host.
+
+## Run
+
+From the shared Docker host (stack dir `/opt/otopcua-mtconnect`):
+
+```bash
+docker compose up -d --wait
+docker compose logs -f agent
+docker compose down
+```
+
+From a dev box via the helper (see CLAUDE.md "Docker Workflow"):
+
+```powershell
+lmxopcua-fix sync mtconnect # push this folder to /opt/otopcua-mtconnect/
+lmxopcua-fix up mtconnect # single-service-shaped stack, no profile argument
+lmxopcua-fix logs mtconnect
+lmxopcua-fix down mtconnect
+```
+
+### Running it on a Mac
+
+macOS **squats port 5000** — AirPlay Receiver (ControlCenter) binds `*:5000` and wins the race,
+so `docker port` reports a healthy publish while every request answers `403 Forbidden` with
+`Server: AirTunes`. Use the host-port override:
+
+```bash
+MTCONNECT_AGENT_HOST_PORT=5555 docker compose up -d --wait
+MTCONNECT_AGENT_ENDPOINT=http://127.0.0.1:5555 dotnet test tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests
+```
+
+## Endpoint
+
+- Default: `http://10.100.0.35:5000` (the shared Docker host; 5000 is the Agent's own default port).
+- Override with `MTCONNECT_AGENT_ENDPOINT` to point at a real Agent on a machine tool.
+- `MTCONNECT_AGENT_HOST_PORT` changes only the **published host port** of the fixture container.
+
+`MTConnectAgentFixture` issues one `GET {endpoint}/probe` at collection init and records a
+`SkipReason` when it fails, so the suite skips cleanly on a box with no fixture running.
+
+## The seeded device model
+
+Every named DataItem deliberately has `name != id` — the inverse of the driver's hand-authored
+unit fixtures, and the only arrangement under which confusing the browse name with the
+observation correlation key is visible.
+
+| DataItem `id` | `name` | Category | Type | Repr. | Inferred type |
+|---|---|---|---|---|---|
+| `fixture_avail` | `Favail` | EVENT | AVAILABILITY | | String |
+| `fixture_x_pos` | `Xact` | SAMPLE | POSITION (ACTUAL, MILLIMETER) | | Float64 — **moves** |
+| `fixture_x_load` | `Xload` | SAMPLE | LOAD (PERCENT) | | Float64 — **moves** |
+| `fixture_x_travel` | `Xtravel` | CONDITION | POSITION | | String + IsAlarm |
+| `fixture_c_speed` | `Cspeed` | SAMPLE | ROTARY_VELOCITY (REVOLUTION/MINUTE) | | Float64 — **moves** |
+| `fixture_c_temp_series` | `Ctemps` | SAMPLE | TEMPERATURE (CELSIUS) | TIME_SERIES | Float64 **array**, ArrayDim `null` |
+| `fixture_mode` | `Cmode` | EVENT | CONTROLLER_MODE | | String |
+| `fixture_execution` | `Pexec` | EVENT | EXECUTION | | String |
+| `fixture_partcount` | `Pcount` | EVENT | **PART_COUNT** | | **Int64** — the regression case |
+| `fixture_linenumber` | `Pline` | EVENT | LINE_NUMBER | | Int64 |
+| `fixture_program` | `Pprogram` | EVENT | PROGRAM | | String |
+| `fixture_block` | `Pblock` | EVENT | BLOCK | | String — **never fed ⇒ UNAVAILABLE** |
+| `fixture_varset` | `Pvars` | EVENT | VARIABLE | DATA_SET | String (structured ⇒ BadNotSupported) |
+| `fixture_logic` | `Plogic` | CONDITION | LOGIC_PROGRAM | | String + IsAlarm |
+| `fixture_asset_changed` / `fixture_asset_removed` | — | EVENT | ASSET_CHANGED / ASSET_REMOVED | | String |
+
+The Agent additionally injects **its own `` self-model device** (connection status,
+observation update rate, adapter URI). That is normal for every MTConnect 2.x Agent and the test
+suite excludes it from value-plane assertions — its update-rate samples tick whether or not any
+adapter is attached, so including them would let "the stream delivered a changed value" pass
+against an Agent with no data source at all.
+
+## Two things a real Agent taught us (both cost a fixture restart to find)
+
+1. **`agent.cfg` must be pure ASCII.** A single non-ASCII byte — even inside a comment — makes
+ the config parser reject the whole file with a bare `Failed / Stopped at line: N` and exit.
+2. **`sampleCount` is not a DataItem attribute.** It belongs to a TIME_SERIES *observation*. An
+ Agent that meets it on a declaration logs
+ `The following keys were present and not expected: sampleCount` followed by
+ `DataItems: Invalid element 'DataItem'` and **drops the entire data item** from the device
+ model. `MTConnectDataTypeInference` therefore only ever sees `sampleCount = null` from a real
+ Agent, so a live TIME_SERIES tag is always a variable-length array.
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/adapter.py b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/adapter.py
new file mode 100644
index 00000000..06019cc3
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/adapter.py
@@ -0,0 +1,229 @@
+#!/usr/bin/env python3
+"""SHDR adapter feeding the OtOpcUa MTConnect integration-test fixture live data.
+
+The mtconnect/agent image ships *only* the agent binary plus its schemas and styles --
+there is no bundled simulator. Without an adapter the Agent answers /probe correctly but
+reports every observation UNAVAILABLE forever, which cannot prove the two things this
+fixture exists for: that ReadAsync returns real coerced values, and that the driver's
+/sample long poll delivers OnDataChange from a genuinely moving stream.
+
+So this is the data source. It speaks SHDR (the Agent's plain-text adapter protocol) over
+TCP: the Agent dials IN as the client (see agent.cfg's Adapters block), and every line we
+write is `||[||...]`, where matches a DataItem's
+`name` attribute in Devices.xml (falling back to its `id` for the unnamed ones).
+
+Deliberate behaviours -- each one is asserted on by the integration suite:
+
+ * Xact / Xload / Cspeed move on EVERY tick. "The value changed between two reads" is the
+ only assertion that distinguishes a live stream from a cached /current snapshot.
+ * Pcount (PART_COUNT) increments on a slower cadence, so it is both a *changing* value
+ and an integer -- the live half of the UPPER_SNAKE PART_COUNT -> Int64 inference.
+ * Pblock is NEVER written. The Agent therefore reports it UNAVAILABLE for the life of
+ the fixture, which is what exercises UNAVAILABLE -> BadNoCommunication.
+ * Conditions are re-asserted periodically rather than once at connect, so a driver that
+ attaches mid-run still sees them.
+
+Pure standard library on purpose: the container is a stock `python:*-alpine` with this
+file bind-mounted, so the fixture needs no image build step at all (unlike the Modbus /
+S7 fixtures, whose simulators do).
+"""
+
+from __future__ import annotations
+
+import argparse
+import datetime
+import math
+import selectors
+import socket
+import sys
+import threading
+import time
+
+# How often the periodic feed writes a batch of observations. Comfortably faster than the
+# driver's default SampleIntervalMs (1000) so a subscription sees several distinct chunks
+# inside a short test timeout.
+TICK_SECONDS = 0.25
+
+# The Agent's PING/PONG watchdog: it sends `* PING` and expects `* PONG ` back. The
+# number is how long the Agent should wait before declaring us dead.
+PONG_TIMEOUT_MS = 10000
+
+EXECUTION_STATES = ("ACTIVE", "READY", "INTERRUPTED", "ACTIVE", "STOPPED")
+CONTROLLER_MODES = ("AUTOMATIC", "MANUAL", "AUTOMATIC", "SEMI_AUTOMATIC")
+
+
+def timestamp() -> str:
+ """UTC in the ISO-8601 form the Agent expects (milliseconds, trailing Z)."""
+ now = datetime.datetime.now(datetime.timezone.utc)
+
+ return now.strftime("%Y-%m-%dT%H:%M:%S.") + f"{now.microsecond // 1000:03d}Z"
+
+
+class Feed:
+ """One connected Agent. Owns the socket for the life of that connection."""
+
+ def __init__(self, conn: socket.socket, peer: str) -> None:
+ self._conn = conn
+ self._peer = peer
+ self._started = time.monotonic()
+ self._ticks = 0
+
+ # ---- wire ----
+
+ def send(self, line: str) -> None:
+ self._conn.sendall((line + "\n").encode("utf-8"))
+
+ def send_observations(self, pairs: list[tuple[str, str]]) -> None:
+ """One SHDR line carrying several key/value pairs, all sharing one timestamp."""
+ if not pairs:
+ return
+
+ body = "|".join(f"{key}|{value}" for key, value in pairs)
+ self.send(f"{timestamp()}|{body}")
+
+ # ---- content ----
+
+ def send_initial_state(self) -> None:
+ """Everything that is not periodic, sent once as soon as the Agent attaches."""
+ self.send_observations(
+ [
+ ("Favail", "AVAILABLE"),
+ ("Pprogram", "OTOPCUA-FIXTURE.NC"),
+ # DATA_SET representation: the Agent renders these as children,
+ # which is precisely the wire shape the driver demotes to String / BadNotSupported.
+ ("Pvars", "toolNumber=7 offsetX=1.25 offsetZ=-0.5"),
+ ]
+ )
+ self.send_conditions()
+
+ def send_conditions(self) -> None:
+ """SHDR condition form: |||||."""
+ stamp = timestamp()
+ self.send(f"{stamp}|Xtravel|normal||||")
+ self.send(f"{stamp}|Plogic|normal||||")
+
+ def tick(self) -> None:
+ """One periodic batch. Called every TICK_SECONDS."""
+ self._ticks += 1
+ elapsed = time.monotonic() - self._started
+
+ # Continuously-moving SAMPLEs. Rounded to 4 dp so the value is a clean double on
+ # the wire and still differs between any two consecutive ticks.
+ position = round(120.0 + 40.0 * math.sin(elapsed / 2.0), 4)
+ load = round(45.0 + 15.0 * math.sin(elapsed / 3.7), 4)
+ speed = round(1500.0 + 250.0 * math.sin(elapsed / 5.0), 4)
+
+ pairs: list[tuple[str, str]] = [
+ ("Xact", f"{position}"),
+ ("Xload", f"{load}"),
+ ("Cspeed", f"{speed}"),
+ ]
+
+ # PART_COUNT: an integer EVENT that also moves. Slower than the samples so it reads
+ # like a real counter rather than a signal.
+ pairs.append(("Pcount", str(100 + int(elapsed // 5))))
+ pairs.append(("Pline", str(10 + (self._ticks % 90))))
+
+ self.send_observations(pairs)
+
+ # TIME_SERIES form: ||| ...
+ series = " ".join(
+ f"{round(21.5 + 0.5 * math.sin(elapsed + i / 4.0), 3)}" for i in range(8)
+ )
+ self.send(f"{timestamp()}|Ctemps|8|100|{series}")
+
+ # Controlled-vocabulary EVENTs, on their own slow cadences.
+ if self._ticks % 28 == 1:
+ self.send_observations(
+ [("Pexec", EXECUTION_STATES[(self._ticks // 28) % len(EXECUTION_STATES)])]
+ )
+ if self._ticks % 44 == 1:
+ self.send_observations(
+ [("Cmode", CONTROLLER_MODES[(self._ticks // 44) % len(CONTROLLER_MODES)])]
+ )
+
+ # Re-assert conditions occasionally so a late-attaching driver still sees them.
+ if self._ticks % 60 == 0:
+ self.send_conditions()
+
+ # NOTE: Pblock is never written, on purpose. See the module docstring.
+
+
+def handle(conn: socket.socket, peer: str) -> None:
+ feed = Feed(conn, peer)
+ print(f"[adapter] agent connected from {peer}", flush=True)
+
+ selector = selectors.DefaultSelector()
+ selector.register(conn, selectors.EVENT_READ)
+
+ pending = b""
+ next_tick = time.monotonic()
+
+ try:
+ feed.send_initial_state()
+
+ while True:
+ now = time.monotonic()
+ if now >= next_tick:
+ feed.tick()
+ # Absolute schedule, not `now + TICK`: drift-free, and a slow tick cannot
+ # compound into an ever-widening gap the Agent reads as a stall.
+ next_tick += TICK_SECONDS
+ if next_tick < now:
+ next_tick = now + TICK_SECONDS
+
+ for _ in selector.select(timeout=max(0.0, next_tick - time.monotonic())):
+ chunk = conn.recv(4096)
+ if not chunk:
+ print(f"[adapter] agent {peer} closed the connection", flush=True)
+
+ return
+
+ pending += chunk
+ while b"\n" in pending:
+ line, pending = pending.split(b"\n", 1)
+ text = line.decode("utf-8", errors="replace").strip()
+ # The Agent's heartbeat. Answering is mandatory: an Agent that gets no
+ # PONG tears the connection down and every observation goes UNAVAILABLE.
+ if text.startswith("* PING"):
+ feed.send(f"* PONG {PONG_TIMEOUT_MS}")
+ except (BrokenPipeError, ConnectionResetError, OSError) as ex:
+ print(f"[adapter] connection to {peer} ended: {ex}", flush=True)
+ finally:
+ selector.close()
+ try:
+ conn.close()
+ except OSError:
+ pass
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="SHDR adapter for the OtOpcUa MTConnect fixture.")
+ parser.add_argument("--host", default="0.0.0.0", help="Bind address (default: all interfaces).")
+ parser.add_argument("--port", type=int, default=7878, help="SHDR listen port (default: 7878).")
+ args = parser.parse_args()
+
+ listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ listener.bind((args.host, args.port))
+ listener.listen(8)
+ print(f"[adapter] listening on {args.host}:{args.port}", flush=True)
+
+ try:
+ while True:
+ conn, addr = listener.accept()
+ conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
+ # One thread per Agent connection. The Agent reconnects after any restart, and
+ # a serialized accept loop would leave the new connection unserved while the
+ # dead one's socket was still being reaped.
+ threading.Thread(
+ target=handle, args=(conn, f"{addr[0]}:{addr[1]}"), daemon=True
+ ).start()
+ except KeyboardInterrupt:
+ return 0
+ finally:
+ listener.close()
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/agent.cfg b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/agent.cfg
new file mode 100644
index 00000000..bbc22692
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/agent.cfg
@@ -0,0 +1,50 @@
+# MTConnect C++ Agent configuration for the OtOpcUa integration-test fixture.
+#
+# ASCII ONLY. The Agent's config parser rejects the whole file on a non-ASCII byte --
+# even inside a comment -- with a bare "Failed / Stopped at line: N" and exits. An em
+# dash in a comment is enough to make the fixture never start.
+#
+# The image's CMD is `/usr/bin/mtcagent run /mtconnect/config/agent.cfg`, so this file is
+# bind-mounted at exactly that path (see docker-compose.yml). Paths below are ABSOLUTE on
+# purpose: a relative `Devices` is resolved against the Agent's working directory
+# (/home/agent), not against this file, and would silently start an Agent with no device
+# model.
+
+Devices = /mtconnect/config/Devices.xml
+Port = 5000
+ServiceName = OtOpcUaMTConnectFixture
+
+# 2^14 = 16384 observations retained. Generous on purpose: the driver's /sample pump
+# re-baselines through /current when its cursor falls out of the Agent's ring buffer, and
+# a small buffer would make the fixture exercise that recovery path on every run instead
+# of the steady-state streaming the suite is here to prove.
+BufferSize = 14
+CheckpointFrequency = 1000
+MaxAssets = 128
+
+# MTConnect is a read-only source for this driver; the Agent must not accept writes.
+AllowPut = false
+
+SchemaVersion = 2.0
+MonitorConfigFiles = false
+Pretty = true
+
+# The block name must equal the Device name attribute in Devices.xml (OtFixtureCnc) --
+# that is how the Agent binds an adapter connection to a device. "adapter" is the compose
+# service name of the SHDR feeder; the Agent dials OUT to it.
+Adapters {
+ OtFixtureCnc {
+ Host = adapter
+ Port = 7878
+ ReconnectInterval = 2000
+ }
+}
+
+# Log to stdout rather than a file: /mtconnect/log is an image-declared VOLUME that is
+# created root-owned while the Agent runs as uid 1000, so file logging fails to open.
+# stdout also puts adapter-connect / device-model errors straight into `docker logs`,
+# which is how you diagnose a fixture that comes up but reports everything UNAVAILABLE.
+logger_config {
+ logging_level = info
+ output = cout
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/docker-compose.yml b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/docker-compose.yml
new file mode 100644
index 00000000..547861dc
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/Docker/docker-compose.yml
@@ -0,0 +1,79 @@
+# MTConnect integration-test fixture — the official MTConnect C++ Agent, fed live data by
+# a standard-library SHDR adapter.
+#
+# TWO services, and both are required:
+#
+# agent mtconnect/agent (the cppagent image). Serves /probe, /current and /sample on
+# :5000. NOTE the repository name — the source project is `mtconnect/cppagent`
+# but the published image is `mtconnect/agent`; `mtconnect/cppagent` does not
+# exist on Docker Hub.
+# adapter The data source. The agent image ships only the agent binary plus schemas and
+# styles — there is NO bundled simulator — so without this every observation is
+# UNAVAILABLE forever and the suite could prove nothing about reads or streaming.
+# The Agent dials OUT to it (agent.cfg's Adapters block); it is not published to
+# the host.
+#
+# Why pinned: the `latest` tag moves and a fixture that silently changes device-model
+# behaviour turns a driver regression into an unexplained red. Bump deliberately.
+#
+# Usage (from the docker host, stack dir /opt/otopcua-mtconnect):
+# docker compose up -d --wait
+# docker compose down
+#
+# Or from a dev box, via the helper (see CLAUDE.md "Docker Workflow"):
+# lmxopcua-fix sync mtconnect
+# lmxopcua-fix up mtconnect # single-service-shaped stack, no profile argument
+services:
+ agent:
+ image: mtconnect/agent:2.7.0.12
+ container_name: otopcua-mtconnect-agent
+ restart: "no"
+ labels:
+ project: lmxopcua
+ depends_on:
+ adapter:
+ condition: service_started
+ ports:
+ # Host port is overridable because macOS squats :5000 — AirPlay Receiver
+ # (ControlCenter) binds *:5000 and WINS the race, so `docker port` reports a healthy
+ # publish while every request answers "403 Forbidden / Server: AirTunes". On the
+ # Linux docker host the default is correct and needs no override. To run the fixture
+ # on a Mac:
+ # MTCONNECT_AGENT_HOST_PORT=5555 docker compose up -d --wait
+ # MTCONNECT_AGENT_ENDPOINT=http://127.0.0.1:5555 dotnet test ...
+ - "${MTCONNECT_AGENT_HOST_PORT:-5000}:5000"
+ volumes:
+ # Individual FILE binds, not a directory bind: /mtconnect/config is a VOLUME declared
+ # by the image, and mounting this whole folder over it would also drop docker-compose.yml
+ # and adapter.py into the Agent's config directory.
+ - ./agent.cfg:/mtconnect/config/agent.cfg:ro
+ - ./Devices.xml:/mtconnect/config/Devices.xml:ro
+ healthcheck:
+ # The image is alpine-based, so busybox wget is present (there is no curl). A 200 from
+ # /probe is the real readiness signal — the port accepts connections before the device
+ # model is parsed.
+ test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:5000/probe || exit 1"]
+ interval: 5s
+ timeout: 3s
+ retries: 12
+ start_period: 5s
+
+ adapter:
+ image: python:3.13-alpine
+ container_name: otopcua-mtconnect-adapter
+ restart: "no"
+ labels:
+ project: lmxopcua
+ volumes:
+ - ./adapter.py:/fixtures/adapter.py:ro
+ # Stock image + a bind-mounted script: this fixture needs no `build:` step at all, unlike
+ # the Modbus / S7 / AB fixtures whose simulators are built from a Dockerfile.
+ command: ["python", "-u", "/fixtures/adapter.py", "--host", "0.0.0.0", "--port", "7878"]
+ expose:
+ - "7878"
+ healthcheck:
+ test: ["CMD-SHELL", "python -c \"import socket; socket.create_connection(('127.0.0.1', 7878), timeout=2).close()\" || exit 1"]
+ interval: 5s
+ timeout: 3s
+ retries: 6
+ start_period: 3s
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentFixture.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentFixture.cs
new file mode 100644
index 00000000..dad972f8
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentFixture.cs
@@ -0,0 +1,190 @@
+using System.Net.Http;
+using System.Xml.Linq;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests;
+
+///
+/// Reachability probe for a live MTConnect Agent (the mtconnect/agent container in
+/// Docker/docker-compose.yml, or a real Agent on a machine tool). Reads
+/// MTCONNECT_AGENT_ENDPOINT (default http://10.100.0.35:5000 — the shared Docker
+/// host) and issues ONE GET {endpoint}/probe at fixture construction. Each test checks
+/// and calls Assert.Skip when the Agent was unreachable, so a
+/// dev box with no fixture running still passes dotnet test cleanly — the same pattern
+/// as ModbusSimulatorFixture / S7SimulatorFixture.
+///
+///
+///
+/// The probe is an HTTP round-trip, not a TCP connect — unlike the Modbus and S7
+/// fixtures, whose protocols have nothing cheaper. An Agent's port accepts connections
+/// before its device model is parsed, and a mis-authored Devices.xml produces an
+/// Agent that answers TCP and then fails every request. A TCP-only probe would let the
+/// whole suite run and fail confusingly rather than skip with an actionable message.
+///
+///
+/// The probe response is kept () and is the source of
+/// truth every assertion in this suite is written against. Nothing here may hard-code a
+/// DataItem id: the seeded Devices.xml is expected to change, and a real Agent on a
+/// real machine is a completely different model. Tests therefore say "for every DataItem
+/// the Agent declares with category=EVENT and type PART_COUNT, the discovered variable must
+/// be Int64" — a statement that survives any device model, including one with zero such
+/// items (which self-skips rather than passing vacuously).
+///
+///
+/// A collection fixture, so the probe runs once per session rather than once per
+/// test: against a firewalled endpoint each attempt costs the full timeout.
+///
+///
+public sealed class MTConnectAgentFixture : IAsyncDisposable
+{
+ /// The shared Docker host (see CLAUDE.md "Docker Workflow"); port 5000 is the Agent's own default.
+ private const string DefaultEndpoint = "http://10.100.0.35:5000";
+
+ private const string EndpointEnvVar = "MTCONNECT_AGENT_ENDPOINT";
+
+ ///
+ /// Bound on the one-shot reachability probe. Deliberately generous relative to a TCP probe:
+ /// a cold Agent parses its device model on the first request, and a large real-world model
+ /// can take a second or two to serialize.
+ ///
+ private static readonly TimeSpan ProbeTimeout = TimeSpan.FromSeconds(10);
+
+ private static readonly string HowToStart =
+ $"Start the fixture (docker compose -f Docker/docker-compose.yml up -d --wait) or point " +
+ $"{EndpointEnvVar} at a live Agent, then re-run.";
+
+ /// Gets the Agent's base URI, exactly as an authored agentUri would carry it.
+ public string AgentUri { get; }
+
+ /// Gets the skip reason when the Agent is unreachable or unusable; otherwise null.
+ public string? SkipReason { get; }
+
+ ///
+ /// Gets the Agent's own /probe response, parsed as XML — null exactly when
+ /// is set. Tests cross-reference discovery output against this so
+ /// their assertions describe the Agent's declared model rather than a hard-coded fixture.
+ ///
+ public XDocument? ProbeDocument { get; }
+
+ /// Initializes a new instance of the class.
+ public MTConnectAgentFixture()
+ {
+ AgentUri = (Environment.GetEnvironmentVariable(EndpointEnvVar) ?? DefaultEndpoint).Trim().TrimEnd('/');
+
+ if (!Uri.TryCreate(AgentUri, UriKind.Absolute, out var parsed) ||
+ (parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps))
+ {
+ SkipReason = $"{EndpointEnvVar} ('{AgentUri}') is not an absolute http(s) URI. {HowToStart}";
+
+ return;
+ }
+
+ try
+ {
+ using var http = new HttpClient { Timeout = ProbeTimeout };
+ using var response = http.GetAsync($"{AgentUri}/probe").GetAwaiter().GetResult();
+
+ if (!response.IsSuccessStatusCode)
+ {
+ SkipReason =
+ $"MTConnect Agent at {AgentUri} answered /probe with HTTP {(int)response.StatusCode} " +
+ $"({response.ReasonPhrase}). {HowToStart}";
+
+ return;
+ }
+
+ var body = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
+ var document = XDocument.Parse(body);
+
+ // An MTConnectError document (or anything else) served under a 2xx must skip, not run.
+ // The suite's whole value is asserting against a real device model; running it against
+ // a document that is not one produces confusing red rather than an honest skip.
+ if (document.Root is null || document.Root.Name.LocalName != "MTConnectDevices")
+ {
+ SkipReason =
+ $"MTConnect Agent at {AgentUri} answered /probe with a <{document.Root?.Name.LocalName ?? "?"}> " +
+ $"document, not . {HowToStart}";
+
+ return;
+ }
+
+ ProbeDocument = document;
+ }
+ catch (Exception ex)
+ {
+ SkipReason = $"MTConnect Agent at {AgentUri} is unreachable: {ex.GetType().Name}: {ex.Message}. {HowToStart}";
+ }
+ }
+
+ ///
+ /// Gets every DataItem the Agent declares, flattened out of
+ /// with the attributes the type inference consumes.
+ ///
+ ///
+ /// Read straight off the XML with LINQ-to-XML rather than through the driver's own
+ /// MTConnectProbeParser: that parser is what several of these tests are checking, and
+ /// an assertion that ran the code under test to build its own expectation could only ever
+ /// prove the code agrees with itself. Matching is on local names because an MTConnect
+ /// document is namespaced and the namespace URI carries the schema version.
+ ///
+ public IReadOnlyList DeclaredDataItems =>
+ ProbeDocument is null
+ ? []
+ : [.. ProbeDocument.Descendants()
+ .Where(e => e.Name.LocalName == "DataItem")
+ .Select(e => new DeclaredDataItem(
+ Id: (string?)e.Attribute("id") ?? string.Empty,
+ Name: (string?)e.Attribute("name"),
+ Category: (string?)e.Attribute("category") ?? string.Empty,
+ Type: (string?)e.Attribute("type") ?? string.Empty,
+ Units: (string?)e.Attribute("units"),
+ Representation: (string?)e.Attribute("representation"),
+ InComposition: e.Ancestors().Any(a => a.Name.LocalName == "Compositions"),
+ InAgentSelfModel: e.Ancestors().Any(a => a.Name.LocalName == "Agent")))
+ .Where(d => d.Id.Length > 0)];
+
+ ///
+ public ValueTask DisposeAsync() => ValueTask.CompletedTask;
+}
+
+///
+/// One DataItem as the live Agent declares it in its /probe response — the
+/// expectation side of every discovery assertion in this suite.
+///
+/// The id attribute: the observation correlation key, and what discovery must stamp as FullName.
+/// The name attribute when present: cosmetic, and what discovery must use as the browse name.
+/// The category attribute — SAMPLE, EVENT, or CONDITION.
+/// The type attribute in the probe document's UPPER_SNAKE spelling (PART_COUNT, not PartCount).
+/// The units attribute when present.
+/// The representation attribute when present (TIME_SERIES, DATA_SET, …).
+///
+/// true when this DataItem is declared under a <Compositions> element rather
+/// than under a component's own <DataItems>. The driver's discovery walk recurses
+/// <Components> only, so a composition-owned item is legitimately absent from the
+/// browse tree; assertions about full coverage of the device model must exclude these or they
+/// become a false red on any Agent whose model uses compositions.
+///
+///
+/// true when this DataItem belongs to the Agent's own <Agent> self-model
+/// (connection status, observation update rate, adapter URI, …) rather than to a
+/// <Device>. Every MTConnect 2.x Agent publishes one, and its update-rate SAMPLEs
+/// move continuously whether or not any adapter is attached — so a "the stream delivered a
+/// changed value" assertion that did not exclude these would pass against an Agent with no data
+/// source at all, which is exactly the vacuous green this suite must not have.
+///
+public sealed record DeclaredDataItem(
+ string Id,
+ string? Name,
+ string Category,
+ string Type,
+ string? Units,
+ string? Representation,
+ bool InComposition,
+ bool InAgentSelfModel);
+
+/// Collection definition so the reachability probe runs once per test session.
+[Xunit.CollectionDefinition(Name)]
+public sealed class MTConnectAgentCollection : Xunit.ICollectionFixture
+{
+ /// The collection name every test class in this suite joins.
+ public const string Name = "MTConnectAgent";
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentIntegrationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentIntegrationTests.cs
new file mode 100644
index 00000000..2e08a622
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/MTConnectAgentIntegrationTests.cs
@@ -0,0 +1,697 @@
+using System.Collections.Concurrent;
+using System.Globalization;
+using System.Net.Http;
+using System.Net.Sockets;
+using System.Text.Json;
+using System.Xml.Linq;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Commons.Types;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests;
+
+///
+/// The MTConnect driver against a real MTConnect Agent (the mtconnect/agent
+/// fixture in Docker/, or a live Agent on a machine tool via
+/// MTCONNECT_AGENT_ENDPOINT). Everything else in the driver's test surface runs on canned
+/// XML; this suite is the only thing that touches a socket.
+///
+///
+///
+/// What only a live Agent can prove, and therefore what every test here is aimed at:
+///
+///
+///
+///
+/// UPPER_SNAKE type spellings. A /probe document writes
+/// DataItem@type as PART_COUNT; a streams document names the same
+/// concept PartCount. An earlier revision of the inference matched only the
+/// PascalCase spelling, typed every real Agent's part counter as
+/// , and passed every unit test — because the
+/// hand-authored fixtures used the streams spelling in the probe document too.
+///
+///
+///
+///
+/// name differs from id. On a real Agent most DataItems carry
+/// both, and they differ. The canned fixtures use ids that double as names, which is
+/// the one arrangement under which confusing the two is invisible.
+///
+///
+///
+///
+/// A real multipart/x-mixed-replace stream. The canned subscribe tests
+/// drive a fake pump that hands the driver pre-parsed chunks; real boundaries, real
+/// chunked transfer-encoding, and real heartbeats are a different thing entirely.
+///
+///
+///
+///
+/// Assertions are structural, never by literal id. Every expectation is computed from
+/// the Agent's OWN /probe response (),
+/// so the suite survives an edit to the seeded Devices.xml and can be pointed at a
+/// real machine tool. A test whose subject the Agent does not declare (no CONDITION, no
+/// PART_COUNT) Assert.Skips rather than passing vacuously.
+///
+///
+/// Two things this suite deliberately does NOT cover, with reasons.
+/// (a) MTConnectError under HTTP 200. The driver handles it and canned tests
+/// pin it, but a modern Agent cannot be made to produce it: mtconnect/agent 2.7
+/// answers an unknown device with 404 and an out-of-range sequence with 400,
+/// each carrying a well-formed MTConnectError body (verified live). The under-200
+/// shape belongs to older/third-party Agents and stays canned-only.
+/// (b) Writes. MTConnect is a read-only source protocol and the driver implements no
+/// IWritable; the fixture Agent runs AllowPut = false to match.
+///
+///
+[Collection(MTConnectAgentCollection.Name)]
+[Trait("Category", "Integration")]
+[Trait("Driver", "MTConnect")]
+public sealed class MTConnectAgentIntegrationTests(MTConnectAgentFixture agent)
+{
+ ///
+ /// Hard per-test ceiling, in milliseconds. Every test also bounds its own awaits, but a
+ /// live-integration suite must never be able to wedge a build against a half-up fixture —
+ /// an Agent that completes its TCP handshake and then never answers would otherwise hang
+ /// inside the driver's own retry loop.
+ ///
+ private const int TestTimeoutMs = 90_000;
+
+ /// Canonical Opc.Ua.StatusCodes numerics, restated so the assertion names the wire value a client sees.
+ private const uint Good = 0x00000000u;
+
+ private const uint BadNoCommunication = 0x80310000u;
+
+ /// How long a subscription test waits for the live /sample stream to move a value.
+ private static readonly TimeSpan StreamWait = TimeSpan.FromSeconds(45);
+
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ // ---- reachability ----
+
+ ///
+ /// The AdminUI Test Connect path against a live Agent:
+ /// must go green and report a latency.
+ ///
+ [Fact(Timeout = TestTimeoutMs)]
+ public async Task Probe_reports_the_live_agent_reachable()
+ {
+ SkipIfDown();
+
+ var result = await new MTConnectDriverProbe()
+ .ProbeAsync(ProbeConfig(agent.AgentUri), TimeSpan.FromSeconds(15), Ct);
+
+ result.Ok.ShouldBeTrue($"Test Connect must go green against the live Agent. Message: {result.Message}");
+ result.Message.ShouldNotBeNull();
+ result.Message.ShouldContain("/probe");
+ result.Latency.ShouldNotBeNull();
+ }
+
+ ///
+ /// Falsifiability control for : the same
+ /// probe against a port nothing is listening on must go RED. Without this, a probe that
+ /// returned Ok = true unconditionally would satisfy the green test forever.
+ ///
+ [Fact(Timeout = TestTimeoutMs)]
+ public async Task Probe_reports_a_dead_endpoint_as_unreachable()
+ {
+ SkipIfDown();
+
+ var result = await new MTConnectDriverProbe()
+ .ProbeAsync(ProbeConfig($"http://127.0.0.1:{ClosedLoopbackPort()}"), TimeSpan.FromSeconds(5), Ct);
+
+ result.Ok.ShouldBeFalse("a probe against a closed port must fail, or the green probe proves nothing");
+ result.Latency.ShouldBeNull();
+ }
+
+ // ---- discovery: the /probe device model ----
+
+ ///
+ /// DiscoverAsync streams a non-empty, genuinely nested tree, and every leaf in it is
+ /// a DataItem the Agent actually declares — no invented references, and nothing dropped
+ /// except the composition-owned items the walk is documented not to visit.
+ ///
+ [Fact(Timeout = TestTimeoutMs)]
+ public async Task Discovery_streams_the_agents_declared_device_model()
+ {
+ SkipIfDown();
+
+ var capture = await DiscoverAsync();
+
+ capture.Variables.ShouldNotBeEmpty("a live Agent declares data items; an empty browse tree is the #485 shape");
+ capture.Folders.ShouldNotBeEmpty();
+ capture.Folders.ShouldContain(
+ f => f.ParentPath.Length > 0,
+ "the walk must recurse , not just emit one folder per device");
+
+ var declared = agent.DeclaredDataItems;
+ var declaredIds = declared.Select(d => d.Id).ToHashSet(StringComparer.Ordinal);
+ var discoveredIds = capture.Variables.Select(v => v.Attr.FullName).ToHashSet(StringComparer.Ordinal);
+
+ discoveredIds.Except(declaredIds).ShouldBeEmpty("discovery must not invent references the Agent never declared");
+
+ var expected = declared.Where(d => !d.InComposition).Select(d => d.Id).ToHashSet(StringComparer.Ordinal);
+ expected.Except(discoveredIds).ShouldBeEmpty("every component-owned DataItem the Agent declares must be browsable");
+ }
+
+ ///
+ /// The regression this whole fixture exists for. An EVENT whose probe-document
+ /// type is the UPPER_SNAKE PART_COUNT / LINE_NUMBER must infer
+ /// . Matching only the streams document's PascalCase
+ /// PartCount types it — green in every unit test,
+ /// wrong on every real Agent.
+ ///
+ [Fact(Timeout = TestTimeoutMs)]
+ public async Task Discovery_types_an_upper_snake_numeric_event_as_Int64()
+ {
+ SkipIfDown();
+
+ var numericEvents = agent.DeclaredDataItems
+ .Where(d => Is(d.Category, "EVENT"))
+ .Where(d => Is(d.Type, "PART_COUNT") || Is(d.Type, "LINE_NUMBER") || Is(d.Type, "LINE"))
+ .ToList();
+
+ if (numericEvents.Count == 0)
+ {
+ Assert.Skip(
+ $"The Agent at {agent.AgentUri} declares no PART_COUNT / LINE_NUMBER EVENT, so the " +
+ "UPPER_SNAKE numeric-event inference has nothing to assert against here.");
+ }
+
+ var capture = await DiscoverAsync();
+
+ foreach (var item in numericEvents)
+ {
+ var variable = Leaf(capture, item.Id);
+ variable.Attr.DriverDataType.ShouldBe(
+ DriverDataType.Int64,
+ $"DataItem '{item.Id}' (category={item.Category}, type={item.Type}) is an integer EVENT; " +
+ "typing it String is the UPPER_SNAKE-vs-PascalCase defect this suite exists to catch");
+ variable.Attr.IsArray.ShouldBeFalse();
+ }
+ }
+
+ ///
+ /// A SAMPLE is a measured quantity by definition of the category, so it must infer
+ /// — scalar for an ordinary sample, an array for a
+ /// TIME_SERIES.
+ ///
+ [Fact(Timeout = TestTimeoutMs)]
+ public async Task Discovery_types_samples_as_Float64_and_time_series_as_a_float_array()
+ {
+ SkipIfDown();
+
+ var samples = agent.DeclaredDataItems
+ .Where(d => Is(d.Category, "SAMPLE") && !d.InComposition)
+ .Where(d => !Is(d.Representation, "DATA_SET") && !Is(d.Representation, "TABLE"))
+ .ToList();
+
+ if (samples.Count == 0)
+ {
+ Assert.Skip($"The Agent at {agent.AgentUri} declares no scalar/time-series SAMPLE data items.");
+ }
+
+ var capture = await DiscoverAsync();
+
+ foreach (var item in samples)
+ {
+ var variable = Leaf(capture, item.Id);
+ variable.Attr.DriverDataType.ShouldBe(
+ DriverDataType.Float64,
+ $"DataItem '{item.Id}' is category=SAMPLE (type={item.Type}, units={item.Units ?? ""})");
+
+ if (Is(item.Representation, "TIME_SERIES"))
+ {
+ variable.Attr.IsArray.ShouldBeTrue($"'{item.Id}' declares representation=TIME_SERIES");
+
+ // Not a bug and not an oversight: `sampleCount` is an attribute of a TIME_SERIES
+ // *observation*, not of a DataItem declaration. A real Agent REJECTS the whole
+ // DataItem if the declaration carries one ("The following keys were present and not
+ // expected: sampleCount" -> "Invalid element 'DataItem'"), so the inference can only
+ // ever see null here and a live TIME_SERIES tag is always variable-length. Asserted
+ // rather than ignored so a future change that starts fabricating a dimension is loud.
+ variable.Attr.ArrayDim.ShouldBeNull(
+ $"'{item.Id}': a real Agent never carries sampleCount on a DataItem declaration");
+ }
+ else
+ {
+ variable.Attr.IsArray.ShouldBeFalse($"'{item.Id}' declares no TIME_SERIES representation");
+ }
+ }
+ }
+
+ ///
+ /// A CONDITION is a state word, never a number, whatever its type says — and it
+ /// is the one category discovery flags as an alarm.
+ ///
+ [Fact(Timeout = TestTimeoutMs)]
+ public async Task Discovery_types_a_condition_as_a_string_alarm()
+ {
+ SkipIfDown();
+
+ var conditions = agent.DeclaredDataItems
+ .Where(d => Is(d.Category, "CONDITION") && !d.InComposition)
+ .ToList();
+
+ if (conditions.Count == 0)
+ {
+ Assert.Skip($"The Agent at {agent.AgentUri} declares no CONDITION data items.");
+ }
+
+ var capture = await DiscoverAsync();
+
+ foreach (var item in conditions)
+ {
+ var variable = Leaf(capture, item.Id);
+ variable.Attr.DriverDataType.ShouldBe(DriverDataType.String, $"CONDITION '{item.Id}' reports a state word");
+ variable.Attr.IsAlarm.ShouldBeTrue($"CONDITION '{item.Id}' is an alarm-bearing data item");
+ variable.Attr.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
+ }
+ }
+
+ ///
+ /// The FullName-vs-browse-name split, on the shape that makes it visible. The
+ /// driver-side reference must be the DataItem's id (the observation correlation key)
+ /// while the browse name is its name. Committing the name instead produces a picker
+ /// that looks perfect and authors tags that can never report a value — and it is invisible on
+ /// the canned fixtures, whose ids double as names.
+ ///
+ [Fact(Timeout = TestTimeoutMs)]
+ public async Task Discovery_binds_FullName_to_the_DataItem_id_when_the_name_differs()
+ {
+ SkipIfDown();
+
+ var renamed = agent.DeclaredDataItems
+ .Where(d => !d.InComposition)
+ .Where(d => !string.IsNullOrEmpty(d.Name) && !string.Equals(d.Name, d.Id, StringComparison.Ordinal))
+ .ToList();
+
+ if (renamed.Count == 0)
+ {
+ Assert.Skip(
+ $"Every DataItem the Agent at {agent.AgentUri} declares has name == id (or no name), so " +
+ "the id-vs-name split cannot be observed against this device model.");
+ }
+
+ var capture = await DiscoverAsync();
+
+ foreach (var item in renamed)
+ {
+ var variable = Leaf(capture, item.Id);
+ variable.Attr.FullName.ShouldBe(item.Id, "the driver-side reference must be the DataItem id");
+ variable.BrowseName.ShouldBe(item.Name, "the browse name must prefer the DataItem name");
+ }
+ }
+
+ ///
+ /// A device scope that matches nothing must fail loudly. It must never degrade to an
+ /// empty-but-successful browse — indistinguishable from a working connection to a machine
+ /// that declares nothing (#485).
+ ///
+ [Fact(Timeout = TestTimeoutMs)]
+ public async Task Discovery_scoped_to_an_unknown_device_fails_rather_than_browsing_empty()
+ {
+ SkipIfDown();
+
+ var driver = new MTConnectDriver(
+ LiveOptions(deviceName: $"NoSuchDevice-{Guid.NewGuid():N}"), "mtconnect-live-unknown-device");
+ var capture = new DiscoveryCapture();
+
+ try
+ {
+ // Either leg may be the one that fails — the Agent answers /probe and /current for an
+ // unknown device with 404 + an MTConnectError body — so the assertion is that ONE of
+ // them did, and that nothing was streamed.
+ var failed = false;
+ try
+ {
+ await driver.InitializeAsync(ConfigJson(LiveOptions(deviceName: $"NoSuchDevice-{Guid.NewGuid():N}")), Ct);
+ await driver.DiscoverAsync(capture, Ct);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ failed = true;
+ }
+
+ failed.ShouldBeTrue("an unknown device scope must surface as a failure, never as an empty browse");
+ capture.Variables.ShouldBeEmpty();
+ }
+ finally
+ {
+ await driver.ShutdownAsync(CancellationToken.None);
+ }
+ }
+
+ // ---- reads: the /current snapshot ----
+
+ ///
+ /// ReadAsync returns live, coerced values off the Agent's /current snapshot for
+ /// the numeric samples it is currently reporting.
+ ///
+ [Fact(Timeout = TestTimeoutMs)]
+ public async Task Read_returns_live_values_for_the_agents_numeric_samples()
+ {
+ SkipIfDown();
+
+ var ids = await NumericSampleIdsAsync();
+ if (ids.Count == 0)
+ {
+ Assert.Skip(
+ $"The Agent at {agent.AgentUri} is currently reporting no numeric SAMPLE value " +
+ "(every sample is UNAVAILABLE). Is the fixture's adapter service running?");
+ }
+
+ await using var live = await LiveDriverAsync(ids.Select(id => Tag(id, DriverDataType.Float64)));
+
+ var results = await live.Driver.ReadAsync([.. ids], Ct);
+
+ results.Count.ShouldBe(ids.Count);
+
+ var good = results.Where(r => r.StatusCode == Good).ToList();
+ good.ShouldNotBeEmpty("at least one numeric SAMPLE the Agent reports a value for must read Good");
+ good.ShouldAllBe(r => r.Value is double);
+ good.ShouldAllBe(r => r.SourceTimestampUtc != null && r.SourceTimestampUtc.Value.Kind == DateTimeKind.Utc);
+ }
+
+ ///
+ /// The Agent's literal UNAVAILABLE must map to BadNoCommunication — not to a
+ /// Good empty string, and not to the "no value yet" code, which means something different to
+ /// an operator.
+ ///
+ [Fact(Timeout = TestTimeoutMs)]
+ public async Task Read_maps_an_unavailable_observation_to_BadNoCommunication()
+ {
+ SkipIfDown();
+
+ var unavailable = (await CurrentAsync())
+ .Descendants()
+ .Where(e => (string?)e.Attribute("dataItemId") is { Length: > 0 })
+ .Where(e => !e.HasElements && string.Equals(e.Value.Trim(), "UNAVAILABLE", StringComparison.Ordinal))
+ .Select(e => (string)e.Attribute("dataItemId")!)
+ .Distinct(StringComparer.Ordinal)
+ .ToList();
+
+ if (unavailable.Count == 0)
+ {
+ Assert.Skip(
+ $"The Agent at {agent.AgentUri} is currently reporting no UNAVAILABLE observation, so the " +
+ "UNAVAILABLE -> BadNoCommunication mapping has nothing live to assert against.");
+ }
+
+ await using var live = await LiveDriverAsync(unavailable.Select(id => Tag(id, DriverDataType.String)));
+
+ var results = await live.Driver.ReadAsync([.. unavailable], Ct);
+
+ results.Count.ShouldBe(unavailable.Count);
+ results.ShouldAllBe(r => r.StatusCode == BadNoCommunication);
+ results.ShouldAllBe(r => r.Value == null);
+ }
+
+ // ---- subscriptions: the /sample long poll ----
+
+ ///
+ /// The live streaming proof. A real multipart/x-mixed-replace body, with real
+ /// boundaries and real heartbeats, must drive OnDataChange — and must drive it with a
+ /// value that has actually MOVED since the primed /current baseline. Asserting merely
+ /// that a callback arrived would be satisfied by the priming callback alone, which is
+ /// answered out of the snapshot and proves nothing about the stream.
+ ///
+ [Fact(Timeout = TestTimeoutMs)]
+ public async Task Subscribe_delivers_a_changed_value_from_the_live_sample_stream()
+ {
+ SkipIfDown();
+
+ var ids = await NumericSampleIdsAsync();
+ if (ids.Count == 0)
+ {
+ Assert.Skip(
+ $"The Agent at {agent.AgentUri} is currently reporting no numeric SAMPLE value, so no " +
+ "observation can be expected to move. Is the fixture's adapter service running?");
+ }
+
+ await using var live = await LiveDriverAsync(ids.Select(id => Tag(id, DriverDataType.Float64)));
+
+ var seen = new ConcurrentQueue();
+ var moved = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var baseline = new ConcurrentDictionary(StringComparer.Ordinal);
+
+ live.Driver.OnDataChange += (_, e) =>
+ {
+ seen.Enqueue(e);
+ if (e.Snapshot.StatusCode != Good || e.Snapshot.Value is not double value)
+ {
+ return;
+ }
+
+ // First Good value per reference is the baseline (the priming callback); a later one
+ // that differs is the stream doing its job.
+ if (!baseline.TryAdd(e.FullReference, value) &&
+ baseline.TryGetValue(e.FullReference, out var first) &&
+ Math.Abs(first - value) > 1e-9)
+ {
+ moved.TrySetResult();
+ }
+ };
+
+ var handle = await live.Driver.SubscribeAsync([.. ids], TimeSpan.FromMilliseconds(200), Ct);
+ try
+ {
+ using var deadline = CancellationTokenSource.CreateLinkedTokenSource(Ct);
+ deadline.CancelAfter(StreamWait);
+
+ var finished = await Task.WhenAny(moved.Task, Task.Delay(Timeout.Infinite, deadline.Token));
+
+ (finished == moved.Task).ShouldBeTrue(
+ $"the live /sample stream must deliver a CHANGED value within {StreamWait.TotalSeconds:F0}s. " +
+ $"Callbacks seen: {seen.Count}; references with a Good baseline: {baseline.Count}.");
+
+ seen.Count.ShouldBeGreaterThan(ids.Count, "more callbacks than the one priming callback per reference");
+ }
+ finally
+ {
+ await live.Driver.UnsubscribeAsync(handle, CancellationToken.None);
+ }
+ }
+
+ // ---- the v3 production shape: RawPath identity ----
+
+ ///
+ /// The shape a deployed driver actually serves: the artifact injects RawTags
+ /// (RawPath + a TagConfig blob naming the DataItem id), and the driver must read and
+ /// publish by RawPath. A driver that answered under its own DataItem id would be
+ /// dropped silently by DriverHostActor's RawPath-keyed routing table, with every unit
+ /// test still green.
+ ///
+ [Fact(Timeout = TestTimeoutMs)]
+ public async Task Read_and_subscribe_key_on_RawPath_when_the_artifact_supplies_RawTags()
+ {
+ SkipIfDown();
+
+ var ids = (await NumericSampleIdsAsync()).Take(4).ToList();
+ if (ids.Count == 0)
+ {
+ Assert.Skip($"The Agent at {agent.AgentUri} is currently reporting no numeric SAMPLE value.");
+ }
+
+ var rawPaths = ids.ToDictionary(id => id, id => $"Plant/MTConnect/live/{id}", StringComparer.Ordinal);
+
+ // The real merge DeploymentArtifact.TryReadSpec runs, not a hand-written JSON literal: this
+ // proves the driver binds the shape the deploy artifact actually produces.
+ var merged = DriverDeviceConfigMerger.Merge(
+ ConfigJson(LiveOptions()),
+ [new DriverDeviceConfigMerger.DeviceRow("live", "{}")],
+ [.. ids.Select(id => new RawTagEntry(
+ rawPaths[id],
+ $$"""{"fullName":"{{id}}","driverDataType":"Float64"}""",
+ WriteIdempotent: false,
+ DeviceName: "live"))]);
+
+ var driver = new MTConnectDriver(LiveOptions(), "mtconnect-live-rawtags");
+ try
+ {
+ await driver.InitializeAsync(merged, Ct);
+
+ var byRawPath = await driver.ReadAsync([.. rawPaths.Values], Ct);
+ byRawPath.Count.ShouldBe(ids.Count);
+ byRawPath.ShouldContain(r => r.StatusCode == Good && r.Value is double, "reads must resolve by RawPath");
+
+ var seen = new ConcurrentQueue();
+ driver.OnDataChange += (_, e) => seen.Enqueue(e);
+
+ var handle = await driver.SubscribeAsync([.. rawPaths.Values], TimeSpan.FromMilliseconds(200), Ct);
+ try
+ {
+ seen.ShouldNotBeEmpty("subscribe primes every reference from the /current snapshot");
+ seen.Select(e => e.FullReference)
+ .ShouldAllBe(reference => rawPaths.Values.Contains(reference));
+ }
+ finally
+ {
+ await driver.UnsubscribeAsync(handle, CancellationToken.None);
+ }
+ }
+ finally
+ {
+ await driver.ShutdownAsync(CancellationToken.None);
+ }
+ }
+
+ // ---- helpers ----
+
+ private void SkipIfDown()
+ {
+ if (agent.SkipReason is not null)
+ {
+ Assert.Skip(agent.SkipReason);
+ }
+ }
+
+ private static bool Is(string? value, string expected) =>
+ string.Equals(value?.Trim(), expected, StringComparison.OrdinalIgnoreCase);
+
+ /// The minimal Test Connect config: the probe reads nothing but agentUri.
+ private static string ProbeConfig(string agentUri) =>
+ JsonSerializer.Serialize(new { agentUri });
+
+ ///
+ /// A loopback port with nothing listening on it: bound to :0 to let the OS pick a free one,
+ /// then released. Racy in principle, effectively certain in practice, and far more reliable
+ /// than guessing an unused constant.
+ ///
+ private static int ClosedLoopbackPort()
+ {
+ var listener = new TcpListener(System.Net.IPAddress.Loopback, 0);
+ listener.Start();
+ var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
+ listener.Stop();
+
+ return port;
+ }
+
+ private MTConnectDriverOptions LiveOptions(string? deviceName = null) =>
+ new()
+ {
+ AgentUri = agent.AgentUri,
+ DeviceName = deviceName,
+ RequestTimeoutMs = 15_000,
+
+ // Faster than the 1000 ms default so a subscription sees several distinct chunks inside
+ // StreamWait; the heartbeat stays well under it so a silent Agent is caught, not waited on.
+ SampleIntervalMs = 200,
+ SampleCount = 500,
+ HeartbeatMs = 5_000,
+
+ // The background connectivity probe is off: it dials the Agent on its own cadence and
+ // would interleave request logs with the ones a failing test needs to be read from.
+ Probe = new MTConnectProbeOptions { Enabled = false },
+ };
+
+ private static string ConfigJson(MTConnectDriverOptions options, IEnumerable? tags = null) =>
+ JsonSerializer.Serialize(new
+ {
+ agentUri = options.AgentUri,
+ deviceName = options.DeviceName,
+ requestTimeoutMs = options.RequestTimeoutMs,
+ sampleIntervalMs = options.SampleIntervalMs,
+ sampleCount = options.SampleCount,
+ heartbeatMs = options.HeartbeatMs,
+ probe = new { enabled = false },
+ tags = (tags ?? []).Select(t => new
+ {
+ fullName = t.FullName,
+ driverDataType = t.DriverDataType.ToString(),
+ }),
+ });
+
+ private static MTConnectTagDefinition Tag(string dataItemId, DriverDataType type) => new(dataItemId, type);
+
+ /// Initializes a driver against the live Agent with the supplied authored tags.
+ private async Task LiveDriverAsync(IEnumerable tags)
+ {
+ var options = LiveOptions();
+ var driver = new MTConnectDriver(options, "mtconnect-live");
+ await driver.InitializeAsync(ConfigJson(options, tags), Ct);
+
+ return new LiveDriver(driver);
+ }
+
+ /// Initializes a driver and captures one full DiscoverAsync pass.
+ private async Task DiscoverAsync()
+ {
+ await using var live = await LiveDriverAsync([]);
+
+ var capture = new DiscoveryCapture();
+ await live.Driver.DiscoverAsync(capture, Ct);
+
+ return capture;
+ }
+
+ /// The single captured variable for a DataItem id, asserting it was streamed exactly once.
+ private static CapturedVariable Leaf(DiscoveryCapture capture, string dataItemId)
+ {
+ var matches = capture.Variables.Where(v => v.Attr.FullName == dataItemId).ToList();
+ matches.Count.ShouldBe(1, $"DataItem '{dataItemId}' must be streamed exactly once by discovery");
+
+ return matches[0];
+ }
+
+ /// The Agent's /current snapshot, fetched straight over HTTP.
+ ///
+ /// Read outside the driver on purpose: several assertions need to know what the Agent is
+ /// reporting right now in order to build their expectation, and computing that with
+ /// the parser under test would only prove the code agrees with itself.
+ ///
+ private async Task CurrentAsync()
+ {
+ using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(15) };
+ var body = await http.GetStringAsync($"{agent.AgentUri}/current", Ct);
+
+ return XDocument.Parse(body);
+ }
+
+ ///
+ /// The DataItem ids the Agent is currently reporting a parseable number for: the live subset
+ /// a read or subscription can make a non-vacuous statement about.
+ ///
+ ///
+ /// The Agent's own self-model is excluded, and that exclusion is load-bearing. Every
+ /// MTConnect 2.x Agent publishes an <Agent> device whose
+ /// OBSERVATION_UPDATE_RATE / ASSET_UPDATE_RATE SAMPLEs tick continuously
+ /// regardless of whether any adapter is attached. Including them made
+ /// pass with the
+ /// fixture's data source deliberately stopped (verified) — it was proving the Agent's own
+ /// heartbeat, not the device stream. Restricted to <Device>-owned samples, an
+ /// Agent with no live source yields an empty set and the test skips with an actionable
+ /// message instead.
+ ///
+ private async Task> NumericSampleIdsAsync()
+ {
+ var declaredSamples = agent.DeclaredDataItems
+ .Where(d => Is(d.Category, "SAMPLE") && !d.InAgentSelfModel && !d.InComposition)
+ .Where(d => !Is(d.Representation, "TIME_SERIES") && !Is(d.Representation, "DATA_SET") && !Is(d.Representation, "TABLE"))
+ .Select(d => d.Id)
+ .ToHashSet(StringComparer.Ordinal);
+
+ return [.. (await CurrentAsync())
+ .Descendants()
+ .Where(e => !e.HasElements)
+ .Select(e => ((string?)e.Attribute("dataItemId"), e.Value.Trim()))
+ .Where(pair => pair.Item1 is { Length: > 0 } && declaredSamples.Contains(pair.Item1))
+ .Where(pair => double.TryParse(pair.Item2, NumberStyles.Float, CultureInfo.InvariantCulture, out _))
+ .Select(pair => pair.Item1!)
+ .Distinct(StringComparer.Ordinal)];
+ }
+
+ ///
+ /// Scope guard: is not disposable (its teardown is
+ /// ShutdownAsync), and a test that failed mid-way would otherwise leave a live
+ /// /sample long poll running against the fixture for the rest of the session.
+ ///
+ private sealed class LiveDriver(MTConnectDriver driver) : IAsyncDisposable
+ {
+ public MTConnectDriver Driver { get; } = driver;
+
+ public async ValueTask DisposeAsync() => await Driver.ShutdownAsync(CancellationToken.None);
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests.csproj
new file mode 100644
index 00000000..0499fd05
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests.csproj
@@ -0,0 +1,41 @@
+
+
+
+
+ $(NoWarn);OTOPCUA0001
+
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+ ZB.MOM.WW.OtOpcUa.Driver.MTConnect.IntegrationTests
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CannedAgentClient.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CannedAgentClient.cs
new file mode 100644
index 00000000..47617451
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CannedAgentClient.cs
@@ -0,0 +1,449 @@
+using System.Collections.Concurrent;
+using System.Runtime.CompilerServices;
+using System.Threading.Channels;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
+
+///
+/// The shared test double: an Agent that serves the canned
+/// Fixtures/probe.xml + Fixtures/current.xml documents, counts every call, records
+/// its own disposal, and lets a test drive the /sample chunk sequence by hand.
+///
+///
+///
+/// Deterministic by construction — no timers, no sleeps, no polling. The
+/// /sample leg reads from an unbounded that only a test
+/// fills, and does not return until the driver has actually consumed
+/// the chunk it wrote (each chunk carries its own completion source, signalled after the
+/// enumerator's yield return resumes). A subscription test can therefore say "one
+/// chunk has now been fully processed" as a fact rather than as a timing hope.
+///
+///
+/// It honours the seam's stream-end contract (see
+/// ): cancelling the token is the only way the
+/// enumeration ends without throwing. Closing the scripted stream via
+/// raises , exactly as the
+/// production client does when an Agent drops the connection — so a pump written against the
+/// fake cannot silently pass while mishandling the real one.
+///
+///
+/// Disposal is observable () because "did the driver
+/// actually release the client?" is a behaviour, not an implementation detail: a
+/// ReinitializeAsync that re-points the Agent but leaks the old client keeps a live
+/// connection pool per re-deploy, and nothing else in the test surface would notice.
+///
+///
+internal sealed class CannedAgentClient : IMTConnectAgentClient, IDisposable
+{
+ private readonly CancellationTokenSource _disposeCts = new();
+
+ private readonly Lock _sampleWaitersLock = new();
+ private readonly List<(int Threshold, TaskCompletionSource Waiter)> _sampleWaiters = [];
+
+ /// Chunks a test has scripted but not yet pumped — see .
+ private readonly ConcurrentQueue _script = new();
+
+ ///
+ /// The current/sample stream generation. Replaced (not merely completed) by
+ /// so that a pump which reconnects after a dropped stream gets a
+ /// live channel to read from instead of one that is permanently closed — without that, a
+ /// reconnect test could only ever observe the reconnect failing.
+ ///
+ private Channel _chunks = Channel.CreateUnbounded();
+
+ private int _probeCallCount;
+ private int _currentCallCount;
+ private int _sampleCallCount;
+ private int _disposeCount;
+
+ private CannedAgentClient(MTConnectProbeModel probe, MTConnectStreamsResult current)
+ {
+ Probe = probe;
+ Current = current;
+ }
+
+ ///
+ /// Builds a client serving the repo's canned fixtures — Fixtures/probe.xml for
+ /// /probe and Fixtures/current.xml for /current.
+ ///
+ /// Probe-document fixture path, relative to the test binaries.
+ /// Streams-document fixture path, relative to the test binaries.
+ public static CannedAgentClient FromFixtures(
+ string probeFixture = "Fixtures/probe.xml",
+ string currentFixture = "Fixtures/current.xml") =>
+ new(
+ MTConnectProbeParser.Parse(File.ReadAllText(probeFixture)),
+ MTConnectStreamsParser.Parse(File.ReadAllText(currentFixture)));
+
+ /// Parses a streams fixture into a chunk a test can script onto the sample stream.
+ /// Streams-document fixture path, relative to the test binaries.
+ public static MTConnectStreamsResult Chunk(string fixture) =>
+ MTConnectStreamsParser.Parse(File.ReadAllText(fixture));
+
+ ///
+ /// An Agent scripted for the ring-buffer-overflow story: the driver primes from
+ /// Fixtures/current.xml (nextSequence 108), then the first scripted chunk is
+ /// Fixtures/sample-gap.xml — whose firstSequence (5000) is strictly newer than
+ /// the cursor, i.e. the buffer rolled past the driver — and the second is an ordinary chunk
+ /// that continues contiguously from the re-baseline.
+ ///
+ ///
+ /// The second/current answer is what makes this a story rather than a single
+ /// event: it advertises the buffer the gap chunk revealed (firstSequence 5000 /
+ /// nextSequence 5005), so a driver that re-baselines and resumes from that answer sees
+ /// the follow-up chunk as contiguous. A driver that re-baselined but resumed from the stale
+ /// cursor would report a gap on every subsequent chunk instead.
+ ///
+ public static CannedAgentClient WithGapThenResume()
+ {
+ var client = FromFixtures();
+ var gap = Chunk("Fixtures/sample-gap.xml");
+
+ // 1st /current = the InitializeAsync prime (the fixture). 2nd = the post-gap re-baseline.
+ client.CurrentAnswers.Enqueue(client.Current);
+ client.CurrentAnswers.Enqueue(
+ new MTConnectStreamsResult(gap.InstanceId, gap.NextSequence, gap.FirstSequence, gap.Observations));
+
+ client.ScriptChunks(
+ gap,
+ new MTConnectStreamsResult(
+ gap.InstanceId,
+ gap.NextSequence + 5,
+ gap.NextSequence,
+ [
+ new MTConnectObservation(
+ "dev1_pos", "201.5000", new DateTime(2026, 7, 24, 12, 6, 0, DateTimeKind.Utc)),
+ ]));
+
+ return client;
+ }
+
+ /// The document /probe answers with. Settable so a test can re-shape the model.
+ public MTConnectProbeModel Probe { get; set; }
+
+ ///
+ /// The document /current answers with. Settable so a re-baseline (or an agent-restart
+ /// instanceId change) can be scripted between calls.
+ ///
+ public MTConnectStreamsResult Current { get; set; }
+
+ /// When set, /probe throws this instead of answering.
+ public Exception? ProbeFailure { get; set; }
+
+ /// When set, /current throws this instead of answering.
+ public Exception? CurrentFailure { get; set; }
+
+ ///
+ /// Scripted /current answers, consumed in order: each call dequeues the next one and
+ /// promotes it to , so once the script runs out the Agent keeps
+ /// answering with the last thing it said rather than travelling back in time. This is how a
+ /// test scripts "the prime, then the post-gap re-baseline" without racing the pump for a
+ /// property setter.
+ ///
+ public ConcurrentQueue CurrentAnswers { get; } = new();
+
+ ///
+ /// Scripted one-shot /sample failures: each enumeration dequeues one and throws it.
+ /// Models a transient stream fault the Agent recovers from — e.g. the
+ /// a real cppagent's OUT_OF_RANGE error document
+ /// (served under HTTP 200) surfaces as.
+ ///
+ public ConcurrentQueue SampleFailures { get; } = new();
+
+ ///
+ /// A sticky/sample failure: every enumeration throws it, forever. Models a
+ /// configuration-level fault such as
+ /// , where reconnecting reproduces the
+ /// identical answer — the shape a pump must NOT retry-loop against.
+ ///
+ public Exception? SampleFailure { get; set; }
+
+ ///
+ /// When set, the next/probe parks here until the test completes it, then the
+ /// gate clears itself so later calls answer immediately.
+ ///
+ ///
+ /// This is how a test holds a request "in flight" across a concurrent lifecycle change —
+ /// a shutdown or a re-initialize landing mid-fetch — with no timers and no races of its own.
+ /// The answer is captured when the request lands, not when it completes, so a test can
+ /// change meanwhile and still tell the two documents apart.
+ ///
+ public TaskCompletionSource? ProbeGate { get; set; }
+
+ ///
+ /// When set, /current does not answer until this source completes — an Agent that
+ /// accepted the request and then went quiet. The wait is cancellation-observing, so the
+ /// caller's own deadline is what ends it; the test never sleeps and never races a timer it
+ /// did not set. Leave null for the ordinary immediate answer.
+ ///
+ public TaskCompletionSource? CurrentGate { get; set; }
+
+ ///
+ /// Signalled the moment a /current request lands — before
+ /// parks it. This is the deterministic "the caller is now inside
+ /// the request" barrier a test needs before landing a concurrent lifecycle change on it;
+ /// without it the only alternative is polling on a timer.
+ ///
+ public TaskCompletionSource? CurrentEntered { get; set; }
+
+ /// Number of /probe requests issued against this client.
+ public int ProbeCallCount => Volatile.Read(ref _probeCallCount);
+
+ /// Number of /current requests issued against this client.
+ public int CurrentCallCount => Volatile.Read(ref _currentCallCount);
+
+ /// Number of /sample enumerations started against this client.
+ public int SampleCallCount => Volatile.Read(ref _sampleCallCount);
+
+ /// Number of calls (not clamped — a double-dispose is visible).
+ public int DisposeCount => Volatile.Read(ref _disposeCount);
+
+ /// Whether the client has been disposed at least once.
+ public bool IsDisposed => DisposeCount > 0;
+
+ /// The from sequence the most recent /sample enumeration was opened at.
+ public long? LastSampleFrom { get; private set; }
+
+ ///
+ /// A task completing once has reached
+ /// — the deterministic "the pump has now opened its Nth stream"
+ /// barrier, for the reconnect paths where no chunk is ever delivered and
+ /// therefore cannot be the barrier.
+ ///
+ /// The enumeration count to wait for.
+ public Task WaitForSampleCallsAsync(int count)
+ {
+ lock (_sampleWaitersLock)
+ {
+ if (Volatile.Read(ref _sampleCallCount) >= count)
+ {
+ return Task.CompletedTask;
+ }
+
+ var waiter = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ _sampleWaiters.Add((count, waiter));
+
+ return waiter.Task;
+ }
+ }
+
+ /// Releases any waiter the new call count satisfies.
+ private void ReleaseSampleWaiters(int reached)
+ {
+ lock (_sampleWaitersLock)
+ {
+ for (var i = _sampleWaiters.Count - 1; i >= 0; i--)
+ {
+ if (_sampleWaiters[i].Threshold > reached)
+ {
+ continue;
+ }
+
+ _sampleWaiters[i].Waiter.TrySetResult();
+ _sampleWaiters.RemoveAt(i);
+ }
+ }
+ }
+
+ ///
+ public async Task ProbeAsync(CancellationToken ct)
+ {
+ ObjectDisposedException.ThrowIf(IsDisposed, this);
+ ct.ThrowIfCancellationRequested();
+ Interlocked.Increment(ref _probeCallCount);
+
+ if (ProbeFailure is not null)
+ {
+ throw ProbeFailure;
+ }
+
+ // Captured now: the Agent answers with the document it held when the request landed.
+ var answer = Probe;
+
+ if (ProbeGate is { } gate)
+ {
+ ProbeGate = null;
+ await gate.Task.WaitAsync(ct).ConfigureAwait(false);
+
+ // A real client whose handler was disposed mid-request fails the request; so does this.
+ ObjectDisposedException.ThrowIf(IsDisposed, this);
+ }
+
+ return answer;
+ }
+
+ ///
+ public async Task CurrentAsync(CancellationToken ct)
+ {
+ ObjectDisposedException.ThrowIf(IsDisposed, this);
+ ct.ThrowIfCancellationRequested();
+ Interlocked.Increment(ref _currentCallCount);
+ CurrentEntered?.TrySetResult();
+
+ // The request was accepted; the answer is withheld until the test releases the gate or the
+ // caller's deadline cancels the token.
+ var gate = CurrentGate;
+ if (gate is not null)
+ {
+ await gate.Task.WaitAsync(ct).ConfigureAwait(false);
+ }
+
+ // A scripted answer supersedes — and then becomes — the standing one.
+ if (CurrentAnswers.TryDequeue(out var scripted))
+ {
+ Current = scripted;
+ }
+
+ return CurrentFailure is null ? Current : throw CurrentFailure;
+ }
+
+ ///
+ public async IAsyncEnumerable SampleAsync(
+ long from, [EnumeratorCancellation] CancellationToken ct)
+ {
+ ObjectDisposedException.ThrowIf(IsDisposed, this);
+ ReleaseSampleWaiters(Interlocked.Increment(ref _sampleCallCount));
+ LastSampleFrom = from;
+
+ // Thrown before the first chunk, exactly like a real client that fails its /sample request:
+ // one-shot scripts first, then the sticky "this endpoint will never stream" failure.
+ if (SampleFailures.TryDequeue(out var oneShot))
+ {
+ throw oneShot;
+ }
+
+ if (SampleFailure is { } sticky)
+ {
+ throw sticky;
+ }
+
+ // Bound to THIS generation: EndStream swaps in a fresh channel, so a consumer that
+ // reconnects reads the new one rather than the closed one it just lost.
+ var chunks = Volatile.Read(ref _chunks);
+ using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(ct, _disposeCts.Token);
+ var delivered = 0L;
+
+ while (true)
+ {
+ ScriptedChunk scripted;
+ try
+ {
+ scripted = await chunks.Reader.ReadAsync(lifetime.Token).ConfigureAwait(false);
+ }
+ catch (ChannelClosedException)
+ {
+ // Mirrors the production client: a stream that stops for any reason other than the
+ // caller cancelling is an exception, never a quiet end of enumeration.
+ throw new MTConnectStreamEndedException(
+ MTConnectStreamEndReason.ConnectionClosed, "canned://agent", delivered);
+ }
+
+ delivered++;
+
+ try
+ {
+ yield return scripted.Result;
+ }
+ finally
+ {
+ // Signalled when the consumer is DONE with this chunk — whether it came back for the
+ // next one or abandoned the enumeration. The finally is load-bearing: a chunk that
+ // makes the pump break out (a sequence gap, an agent restart) is never resumed past,
+ // so signalling only after the resume would hang every re-baseline test.
+ scripted.Consumed.TrySetResult();
+ }
+ }
+ }
+
+ ///
+ /// Queues a chunk onto the scripted /sample stream and waits until the consumer has
+ /// processed it. This is the deterministic replacement for "wait a bit and hope the pump
+ /// ran".
+ ///
+ /// The chunk the Agent should send next.
+ /// A task completing once the enumerating consumer has moved past .
+ public Task PumpAsync(MTConnectStreamsResult chunk)
+ {
+ ArgumentNullException.ThrowIfNull(chunk);
+
+ var scripted = new ScriptedChunk(
+ chunk, new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously));
+
+ if (!Volatile.Read(ref _chunks).Writer.TryWrite(scripted))
+ {
+ throw new InvalidOperationException("The scripted /sample stream is already closed.");
+ }
+
+ return scripted.Consumed.Task;
+ }
+
+ ///
+ /// Queues chunks for to deliver one at a time. Nothing is sent until
+ /// a test asks for it — the script is a plan, not a schedule.
+ ///
+ /// The chunks the Agent should send, in order.
+ public void ScriptChunks(params MTConnectStreamsResult[] chunks)
+ {
+ ArgumentNullException.ThrowIfNull(chunks);
+
+ foreach (var chunk in chunks)
+ {
+ _script.Enqueue(chunk);
+ }
+ }
+
+ ///
+ /// Pumps the next scripted chunk (see ) and waits until the
+ /// consumer has finished with it.
+ ///
+ /// A task completing once the consumer has processed — or abandoned the stream on — that chunk.
+ /// The script is exhausted.
+ public Task PumpOnce() =>
+ _script.TryDequeue(out var next)
+ ? PumpAsync(next)
+ : throw new InvalidOperationException(
+ "No scripted /sample chunk left to pump; call ScriptChunks first.");
+
+ ///
+ /// Closes the current scripted /sample stream, which surfaces to its consumer as an
+ /// — the transient, reconnectable end — and opens
+ /// a fresh one so a reconnecting consumer has somewhere to land.
+ ///
+ public void EndStream()
+ {
+ var closed = Interlocked.Exchange(ref _chunks, Channel.CreateUnbounded());
+ closed.Writer.TryComplete();
+ }
+
+ ///
+ ///
+ ///
+ /// The first-disposer test uses the value
+ /// returned, not a re-read of the counter. Re-reading is a race: two concurrent
+ /// disposes can both increment and then both read 2, so BOTH take the early return and
+ /// the stream is never closed — a subscription test would then hang waiting for a
+ /// teardown that silently did nothing.
+ ///
+ ///
+ /// is cancelled but deliberately NOT disposed. A
+ /// /sample enumeration that is starting concurrently reaches
+ /// CreateLinkedTokenSource(ct, _disposeCts.Token), and reading .Token on a
+ /// disposed source throws — a flake that would
+ /// surface as a random unrelated failure in whichever test happened to lose the race.
+ /// Leaking one cancelled source per fake is free; a cancelled source needs no disposal
+ /// to release anything a test cares about.
+ ///
+ ///
+ public void Dispose()
+ {
+ if (Interlocked.Increment(ref _disposeCount) > 1)
+ {
+ return;
+ }
+
+ _disposeCts.Cancel();
+ Volatile.Read(ref _chunks).Writer.TryComplete();
+ }
+
+ private sealed record ScriptedChunk(MTConnectStreamsResult Result, TaskCompletionSource Consumed);
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CapturingBuilder.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CapturingBuilder.cs
new file mode 100644
index 00000000..eed2defd
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CapturingBuilder.cs
@@ -0,0 +1,132 @@
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
+
+///
+/// An that records the tree a driver streams into it, so
+/// DiscoverAsync can be asserted on shape (which node landed under which folder)
+/// and not merely on a flat list of leaves.
+///
+///
+///
+/// Why this exists rather than a reference to the shipped capturing builder. Two
+/// production capturing builders exist — Commons.Browsing.CapturingAddressSpaceBuilder
+/// (universal browser) and Runtime.Drivers.CapturingAddressSpaceBuilder (deploy-time
+/// discovery) — but this suite deliberately references only the driver and its
+/// .Contracts, so neither is reachable without pulling the whole server stack into a
+/// driver unit-test project. Both also flatten differently from what these tests must prove:
+/// the browser's node id for a folder is a synthetic path while a leaf's id is its
+/// FullName, which is exactly the conflation a "did the device-level data item land in
+/// the device folder?" assertion needs to see through.
+///
+///
+/// Deliberately not thread-safe. A driver streams discovery on one caller; recording
+/// behind a lock would hide a driver that fanned out onto other threads.
+///
+///
+internal sealed class CapturingBuilder : IAddressSpaceBuilder
+{
+ private readonly State _state;
+ private readonly string _path;
+
+ /// Creates a root builder — the scope a driver's DiscoverAsync is handed.
+ public CapturingBuilder()
+ {
+ _state = new State();
+ _path = string.Empty;
+ }
+
+ private CapturingBuilder(State state, string path)
+ {
+ _state = state;
+ _path = path;
+ }
+
+ /// Every folder streamed, in call order, with the slash-joined path it landed at.
+ public IReadOnlyList Folders => _state.Folders;
+
+ /// Every variable streamed, in call order, with the folder path it landed under.
+ public IReadOnlyList Variables => _state.Variables;
+
+ /// Every property streamed, with the scope path it was added to.
+ public IReadOnlyList Properties => _state.Properties;
+
+ ///
+ public IAddressSpaceBuilder Folder(string browseName, string displayName)
+ {
+ var path = _path.Length == 0 ? browseName : $"{_path}/{browseName}";
+ _state.Folders.Add(new CapturedFolder(path, _path, browseName, displayName));
+
+ return new CapturingBuilder(_state, path);
+ }
+
+ ///
+ public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
+ {
+ var captured = new CapturedVariable(_path, browseName, displayName, attributeInfo);
+ _state.Variables.Add(captured);
+
+ return new CapturedVariableHandle(captured);
+ }
+
+ ///
+ public void AddProperty(string browseName, DriverDataType dataType, object? value) =>
+ _state.Properties.Add(new CapturedProperty(_path, browseName, dataType, value));
+
+ private sealed class State
+ {
+ public List Folders { get; } = [];
+
+ public List Variables { get; } = [];
+
+ public List Properties { get; } = [];
+ }
+
+ private sealed class CapturedVariableHandle(CapturedVariable variable) : IVariableHandle
+ {
+ public string FullReference => variable.Attr.FullName;
+
+ public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
+ {
+ variable.AlarmConditions.Add(info);
+
+ return new NullSink();
+ }
+
+ private sealed class NullSink : IAlarmConditionSink
+ {
+ public void OnTransition(AlarmEventArgs args)
+ {
+ // Nothing to record: no test in this suite drives an alarm transition through
+ // discovery, and a sink that threw would fail a driver that legitimately never
+ // pushes one.
+ }
+ }
+ }
+}
+
+/// One folder captured from a discovery stream.
+/// Slash-joined path of the folder itself.
+/// Slash-joined path of the scope it was added to (empty at the root).
+/// The browse name the driver supplied.
+/// The display name the driver supplied.
+internal sealed record CapturedFolder(string Path, string ParentPath, string BrowseName, string DisplayName);
+
+/// One variable captured from a discovery stream.
+/// Slash-joined path of the folder it landed under (empty at the root).
+/// The browse name the driver supplied.
+/// The display name the driver supplied.
+/// The driver-side attribute metadata the driver stamped on it.
+internal sealed record CapturedVariable(
+ string ParentPath, string BrowseName, string DisplayName, DriverAttributeInfo Attr)
+{
+ /// Alarm conditions the driver marked this variable with, if any.
+ public List AlarmConditions { get; } = [];
+}
+
+/// One property captured from a discovery stream.
+/// Slash-joined path of the node it was added to.
+/// The property browse name.
+/// The property data type.
+/// The property value.
+internal sealed record CapturedProperty(string ScopePath, string BrowseName, DriverDataType DataType, object? Value);
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/Fixtures/current-unavailable.xml b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/Fixtures/current-unavailable.xml
new file mode 100644
index 00000000..0d25f8df
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/Fixtures/current-unavailable.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+ UNAVAILABLE
+
+
+
+
+ UNAVAILABLE
+ UNAVAILABLE
+
+
+ UNAVAILABLE
+ UNAVAILABLE
+ UNAVAILABLE
+
+
+
+
+
+
+
+
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/Fixtures/current.xml b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/Fixtures/current.xml
new file mode 100644
index 00000000..7b095386
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/Fixtures/current.xml
@@ -0,0 +1,41 @@
+
+
+
+
+
+
+
+ AVAILABLE
+
+
+
+
+
+ 123.4567
+ 1.1 1.2 1.3 1.4 1.5 1.6 1.7 1.8 1.9 2.0
+
+
+
+ UNAVAILABLE
+ ACTIVE
+ O1234
+
+
+
+
+
+
+
+
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/Fixtures/probe.xml b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/Fixtures/probe.xml
new file mode 100644
index 00000000..dcf97716
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/Fixtures/probe.xml
@@ -0,0 +1,46 @@
+
+
+
+
+
+ Test fixture device for MTConnect driver unit tests
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/Fixtures/sample-gap.xml b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/Fixtures/sample-gap.xml
new file mode 100644
index 00000000..bc167cc7
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/Fixtures/sample-gap.xml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+ 200.0000
+ 3.1 3.2 3.3 3.4 3.5 3.6 3.7 3.8 3.9 4.0
+
+
+ 99
+ ACTIVE
+ O5678
+
+
+
+
+
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/Fixtures/sample.xml b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/Fixtures/sample.xml
new file mode 100644
index 00000000..395687b6
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/Fixtures/sample.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+ 124.0100
+ 2.1 2.2 2.3 2.4 2.5 2.6 2.7 2.8 2.9 3.0
+
+
+ 42
+ ACTIVE
+ O1234
+
+
+
+
+
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDataTypeInferenceTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDataTypeInferenceTests.cs
new file mode 100644
index 00000000..487778a2
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDataTypeInferenceTests.cs
@@ -0,0 +1,311 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
+
+///
+/// Golden table for (design §3.3).
+/// Three call sites must agree byte-for-byte on this mapping — ITagDiscovery.DiscoverAsync,
+/// the universal browser's commit path, and the AdminUI typed tag editor — so the table is
+/// pinned here rather than re-derived at each seam.
+///
+[Trait("Category", "Unit")]
+public sealed class MTConnectDataTypeInferenceTests
+{
+ // ---------------------------------------------------------------------
+ // The design §3.3 golden table.
+ // ---------------------------------------------------------------------
+
+ [Theory]
+ // SAMPLE numeric with units → Float64.
+ [InlineData("SAMPLE", "Position", "MILLIMETER", null, DriverDataType.Float64)]
+ [InlineData("SAMPLE", "SpindleSpeed", "REVOLUTION/MINUTE", "VALUE", DriverDataType.Float64)]
+ // EVENT numeric type → Int64.
+ [InlineData("EVENT", "PartCount", null, null, DriverDataType.Int64)]
+ [InlineData("EVENT", "Line", null, null, DriverDataType.Int64)]
+ // EVENT controlled vocabulary → String.
+ [InlineData("EVENT", "Execution", null, null, DriverDataType.String)]
+ [InlineData("EVENT", "ControllerMode", null, null, DriverDataType.String)]
+ [InlineData("EVENT", "Availability", null, null, DriverDataType.String)]
+ // EVENT free text → String.
+ [InlineData("EVENT", "Program", null, null, DriverDataType.String)]
+ [InlineData("EVENT", "Block", null, null, DriverDataType.String)]
+ [InlineData("EVENT", "Message", null, null, DriverDataType.String)]
+ // CONDITION → String (the state word).
+ [InlineData("CONDITION", "Temperature", null, null, DriverDataType.String)]
+ [InlineData("CONDITION", "SystemCondition", null, null, DriverDataType.String)]
+ public void Infer_maps_the_v1_table(string cat, string type, string? units, string? rep, DriverDataType expected)
+ => MTConnectDataTypeInference.Infer(cat, type, units, rep).DataType.ShouldBe(expected);
+
+ // ---------------------------------------------------------------------
+ // The SAME golden table, spelled the way a real agent's /probe document
+ // spells it. MTConnect writes the same concept two ways: the Devices
+ // (/probe) document's `DataItem@type` is UPPER_SNAKE (PART_COUNT), while
+ // the Streams (/current, /sample) document's observation element name is
+ // PascalCase (PartCount). `DiscoverAsync` feeds the *probe* spelling, so
+ // an inference that only knows the PascalCase spelling is green in tests
+ // and wrong live. Every row below is a DataItem from Fixtures/probe.xml.
+ // ---------------------------------------------------------------------
+
+ [Theory]
+ [InlineData("EVENT", "PART_COUNT", null, DriverDataType.Int64)] // dev1_partcount
+ [InlineData("SAMPLE", "POSITION", "MILLIMETER", DriverDataType.Float64)] // dev1_pos
+ [InlineData("EVENT", "EXECUTION", null, DriverDataType.String)] // dev1_execution
+ [InlineData("EVENT", "PROGRAM", null, DriverDataType.String)] // dev1_program
+ [InlineData("EVENT", "AVAILABILITY", null, DriverDataType.String)] // dev1_avail
+ [InlineData("CONDITION", "SYSTEM", null, DriverDataType.String)] // dev1_system_cond
+ public void Infer_maps_the_probe_documents_UPPER_SNAKE_spelling(
+ string cat, string type, string? units, DriverDataType expected)
+ => MTConnectDataTypeInference.Infer(cat, type, units, null).DataType.ShouldBe(expected);
+
+ [Fact]
+ public void The_probes_TIME_SERIES_item_is_a_float64_array_of_its_declared_sample_count()
+ {
+ // dev1_vibration_ts: category=SAMPLE type=PATH_FEEDRATE representation=TIME_SERIES sampleCount=10.
+ var r = MTConnectDataTypeInference.Infer(
+ "SAMPLE", "PATH_FEEDRATE", "MILLIMETER/SECOND", "TIME_SERIES", sampleCount: 10);
+
+ r.DataType.ShouldBe(DriverDataType.Float64);
+ r.IsArray.ShouldBeTrue();
+ r.ArrayDim.ShouldBe(10u);
+ }
+
+ [Theory]
+ [InlineData("PART_COUNT", "PartCount")]
+ [InlineData("LINE_NUMBER", "LineNumber")]
+ [InlineData("part_count", "partcount")]
+ [InlineData("PART-COUNT", "PartCount")]
+ [InlineData("PART_COUNT", " Part Count ")]
+ public void The_two_document_spellings_of_one_type_infer_identically(string probeSpelling, string streamsSpelling)
+ {
+ // The equivalence is the whole point: a tag discovered from /probe and the same tag
+ // named from a /current observation must land on one type, or the authored config
+ // stops matching the value that arrives.
+ var fromProbe = MTConnectDataTypeInference.Infer("EVENT", probeSpelling, null, null);
+ var fromStreams = MTConnectDataTypeInference.Infer("EVENT", streamsSpelling, null, null);
+
+ fromProbe.ShouldBe(fromStreams);
+ fromProbe.DataType.ShouldBe(DriverDataType.Int64);
+ }
+
+ [Theory]
+ [InlineData("PART_COUNTER")]
+ [InlineData("PARTS_COUNT")]
+ [InlineData("LINE_NUMBERS")]
+ [InlineData("_")]
+ public void Separator_insensitivity_does_not_widen_the_numeric_EVENT_exception_list(string type)
+ // Falsifiability control: ignoring separators must not turn a *different* type into a
+ // match. The exception list stays exactly PartCount / Line / LineNumber.
+ => MTConnectDataTypeInference.Infer("EVENT", type, null, null).DataType.ShouldBe(DriverDataType.String);
+
+ [Fact]
+ public void TimeSeries_sample_is_a_float64_array_with_declared_count()
+ {
+ var r = MTConnectDataTypeInference.Infer("SAMPLE", "Position", "MM", "TIME_SERIES", sampleCount: 8);
+
+ r.DataType.ShouldBe(DriverDataType.Float64);
+ r.IsArray.ShouldBeTrue();
+ r.ArrayDim.ShouldBe(8u);
+ }
+
+ // ---------------------------------------------------------------------
+ // Array shape: only a SAMPLE/TIME_SERIES is an array, and ArrayDim is
+ // only populated when the probe declared a usable sampleCount.
+ // ---------------------------------------------------------------------
+
+ [Fact]
+ public void TimeSeries_without_a_declared_sample_count_is_a_variable_length_array()
+ {
+ var r = MTConnectDataTypeInference.Infer("SAMPLE", "Position", "MM", "TIME_SERIES");
+
+ r.IsArray.ShouldBeTrue();
+ r.ArrayDim.ShouldBeNull();
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ public void TimeSeries_with_a_non_positive_sample_count_reports_no_dimension(int declared)
+ {
+ var r = MTConnectDataTypeInference.Infer("SAMPLE", "Position", "MM", "TIME_SERIES", declared);
+
+ r.IsArray.ShouldBeTrue();
+ r.ArrayDim.ShouldBeNull();
+ }
+
+ [Theory]
+ [InlineData("SAMPLE", "Position", "VALUE")]
+ [InlineData("SAMPLE", "Position", null)]
+ [InlineData("EVENT", "PartCount", "VALUE")]
+ [InlineData("CONDITION", "Temperature", null)]
+ public void A_scalar_item_is_never_an_array(string cat, string type, string? rep)
+ {
+ var r = MTConnectDataTypeInference.Infer(cat, type, null, rep);
+
+ r.IsArray.ShouldBeFalse();
+ r.ArrayDim.ShouldBeNull();
+ }
+
+ [Fact]
+ public void A_declared_sample_count_is_ignored_when_the_item_is_not_a_time_series()
+ {
+ var r = MTConnectDataTypeInference.Infer("SAMPLE", "Position", "MM", "VALUE", sampleCount: 8);
+
+ r.IsArray.ShouldBeFalse();
+ r.ArrayDim.ShouldBeNull();
+ }
+
+ [Theory]
+ [InlineData("EVENT")]
+ [InlineData("CONDITION")]
+ public void TIME_SERIES_outside_the_SAMPLE_category_is_not_treated_as_an_array(string cat)
+ {
+ // TIME_SERIES is defined only for SAMPLE. Honouring it elsewhere would hand the
+ // address space an array node whose observations are scalars.
+ var r = MTConnectDataTypeInference.Infer(cat, "Anything", null, "TIME_SERIES", sampleCount: 8);
+
+ r.IsArray.ShouldBeFalse();
+ r.ArrayDim.ShouldBeNull();
+ r.DataType.ShouldBe(DriverDataType.String);
+ }
+
+ // ---------------------------------------------------------------------
+ // The defaulting rule for unrecognised `type` values — the part of the
+ // table that is a *rule*, not an enumeration. MTConnect defines hundreds
+ // of types; the fallback per category is what actually ships.
+ // ---------------------------------------------------------------------
+
+ [Theory]
+ [InlineData("Xacceleration")]
+ [InlineData("SomeVendorExtension")]
+ [InlineData(null)]
+ [InlineData("")]
+ public void An_unrecognised_SAMPLE_type_still_defaults_to_Float64(string? type)
+ => MTConnectDataTypeInference.Infer("SAMPLE", type, null, null).DataType.ShouldBe(DriverDataType.Float64);
+
+ [Fact]
+ public void A_SAMPLE_without_units_is_still_numeric()
+ // SAMPLE is numeric by definition in the standard; a missing `units` attribute is a
+ // gap in the device model, not evidence that the observation is text.
+ => MTConnectDataTypeInference.Infer("SAMPLE", "Position", null, null).DataType
+ .ShouldBe(DriverDataType.Float64);
+
+ [Theory]
+ [InlineData("ToolNumber")]
+ [InlineData("PalletId")]
+ [InlineData("LineLabel")]
+ [InlineData("SomeVendorExtension")]
+ [InlineData(null)]
+ [InlineData("")]
+ public void An_unrecognised_EVENT_type_defaults_to_String(string? type)
+ // Fail-safe direction: a wrong numeric coercion produces a Bad-quality value at runtime,
+ // whereas String always round-trips and the author can override.
+ => MTConnectDataTypeInference.Infer("EVENT", type, null, null).DataType.ShouldBe(DriverDataType.String);
+
+ [Theory]
+ [InlineData("LineNumber")]
+ public void The_modern_spelling_of_a_known_numeric_EVENT_is_also_numeric(string type)
+ // `Line` was superseded by `LineNumber`; mapping only the deprecated spelling would
+ // silently mistype the same data item on any current-version agent.
+ => MTConnectDataTypeInference.Infer("EVENT", type, null, null).DataType.ShouldBe(DriverDataType.Int64);
+
+ [Theory]
+ [InlineData("Anything")]
+ [InlineData(null)]
+ public void Every_CONDITION_is_a_String_regardless_of_type(string? type)
+ => MTConnectDataTypeInference.Infer("CONDITION", type, "CELSIUS", null).DataType
+ .ShouldBe(DriverDataType.String);
+
+ // ---------------------------------------------------------------------
+ // Unknown / missing category — probe XML in the wild is inconsistent.
+ // ---------------------------------------------------------------------
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("ASSET")]
+ [InlineData("nonsense")]
+ public void An_unknown_or_missing_category_falls_back_to_String(string? cat)
+ // We cannot know the observation is numeric, so we choose the type that always
+ // round-trips rather than one that can fail to parse.
+ => MTConnectDataTypeInference.Infer(cat, "Position", "MILLIMETER", null).DataType
+ .ShouldBe(DriverDataType.String);
+
+ // ---------------------------------------------------------------------
+ // Case / whitespace tolerance.
+ // ---------------------------------------------------------------------
+
+ [Theory]
+ [InlineData("sample", DriverDataType.Float64)]
+ [InlineData("Sample", DriverDataType.Float64)]
+ [InlineData(" SAMPLE ", DriverDataType.Float64)]
+ [InlineData("condition", DriverDataType.String)]
+ public void Category_matching_is_case_and_whitespace_insensitive(string cat, DriverDataType expected)
+ => MTConnectDataTypeInference.Infer(cat, "Position", "MM", null).DataType.ShouldBe(expected);
+
+ [Theory]
+ [InlineData("partcount")]
+ [InlineData("PARTCOUNT")]
+ [InlineData(" PartCount ")]
+ public void Numeric_EVENT_type_matching_is_case_and_whitespace_insensitive(string type)
+ => MTConnectDataTypeInference.Infer("EVENT", type, null, null).DataType.ShouldBe(DriverDataType.Int64);
+
+ [Theory]
+ [InlineData("time_series")]
+ [InlineData("Time_Series")]
+ [InlineData(" TIME_SERIES ")]
+ public void Representation_matching_is_case_and_whitespace_insensitive(string rep)
+ => MTConnectDataTypeInference.Infer("SAMPLE", "Position", "MM", rep, 4).IsArray.ShouldBeTrue();
+
+ // ---------------------------------------------------------------------
+ // Non-scalar representations. A DATA_SET / TABLE observation is a
+ // key-value map, not a number — typing it Float64 guarantees Bad quality.
+ // ---------------------------------------------------------------------
+
+ [Theory]
+ [InlineData("DATA_SET")]
+ [InlineData("TABLE")]
+ [InlineData("data_set")]
+ public void A_key_value_representation_is_a_String_even_on_a_SAMPLE(string rep)
+ {
+ var r = MTConnectDataTypeInference.Infer("SAMPLE", "Position", "MILLIMETER", rep);
+
+ r.DataType.ShouldBe(DriverDataType.String);
+ r.IsArray.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void A_key_value_representation_also_demotes_a_numeric_EVENT()
+ {
+ var r = MTConnectDataTypeInference.Infer("EVENT", "PartCount", null, "DATA_SET");
+
+ r.DataType.ShouldBe(DriverDataType.String);
+ r.IsArray.ShouldBeFalse();
+ }
+
+ [Fact]
+ public void DISCRETE_is_a_scalar_representation_and_does_not_change_the_type()
+ {
+ // DISCRETE changes *when* the agent reports, not what the value is.
+ var r = MTConnectDataTypeInference.Infer("SAMPLE", "Position", "MILLIMETER", "DISCRETE");
+
+ r.DataType.ShouldBe(DriverDataType.Float64);
+ r.IsArray.ShouldBeFalse();
+ }
+
+ // ---------------------------------------------------------------------
+ // Purity — the three call sites rely on identical results for identical
+ // inputs with no shared state between calls.
+ // ---------------------------------------------------------------------
+
+ [Fact]
+ public void Infer_is_deterministic()
+ {
+ var first = MTConnectDataTypeInference.Infer("SAMPLE", "Position", "MM", "TIME_SERIES", 8);
+ var second = MTConnectDataTypeInference.Infer("SAMPLE", "Position", "MM", "TIME_SERIES", 8);
+
+ second.ShouldBe(first);
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDiscoverTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDiscoverTests.cs
new file mode 100644
index 00000000..6e02e552
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDiscoverTests.cs
@@ -0,0 +1,774 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
+
+///
+/// Task 12 — 's half: the
+/// /probe device model streamed into an as
+/// Device → Component → DataItem, and the one-member browse opt-in
+/// () the Wave-0 universal browser keys off.
+///
+///
+///
+/// The load-bearing assertion is FullName == DataItem@id. The universal
+/// browser commits a browsed leaf's straight into
+/// TagConfig.FullName, and that is the key ReadAsync / the /sample pump
+/// resolve an observation against ( is keyed by
+/// DataItem@id). Streaming the browse name instead would produce a picker that
+/// looks perfect and commits tags that can never report a value — every one of them stuck at
+/// BadWaitingForInitialData behind a Healthy driver.
+///
+///
+/// The second load-bearing assertion is the type shape. Discovery must stamp exactly
+/// what returns, including the array shape — the
+/// AdminUI typed editor calls the same function, so any local recomputation here makes the
+/// picker and the editor disagree about the same data item. PART_COUNT → Int64 is
+/// called out on its own because the probe document's UPPER_SNAKE spelling is the case a
+/// PascalCase-only match silently types as String.
+///
+///
+public sealed class MTConnectDiscoverTests
+{
+ private const string AgentUri = "http://fixture-agent:5000";
+
+ /// Every DataItem id the canned Fixtures/probe.xml declares.
+ private static readonly string[] FixtureDataItemIds =
+ [
+ "dev1_avail",
+ "dev1_pos",
+ "dev1_vibration_ts",
+ "dev1_partcount",
+ "dev1_execution",
+ "dev1_program",
+ "dev1_system_cond",
+ ];
+
+ private static MTConnectDriverOptions Opts(string? deviceName = null) =>
+ new()
+ {
+ AgentUri = AgentUri,
+ DeviceName = deviceName,
+
+ // Discovery is a pure read of the /probe device model: it must enumerate what the Agent
+ // declares, NOT what an operator has already authored. Left empty on purpose so a driver
+ // that streamed its authored tag table instead could not pass.
+ Tags = [],
+ };
+
+ private static (MTConnectDriver Driver, CannedAgentClient Client) NewDriver(
+ MTConnectDriverOptions? options = null,
+ MTConnectProbeModel? probe = null,
+ RecordingDriverLogger? logger = null)
+ {
+ var client = CannedAgentClient.FromFixtures();
+ if (probe is not null)
+ {
+ client.Probe = probe;
+ }
+
+ return (new MTConnectDriver(options ?? Opts(), "mt1", _ => client, logger), client);
+ }
+
+ private static async Task<(MTConnectDriver Driver, CannedAgentClient Client)> InitializedDriverAsync(
+ MTConnectDriverOptions? options = null,
+ MTConnectProbeModel? probe = null,
+ RecordingDriverLogger? logger = null)
+ {
+ var (driver, client) = NewDriver(options, probe, logger);
+ await driver.InitializeAsync("{}", TestContext.Current.CancellationToken);
+
+ return (driver, client);
+ }
+
+ private static async Task DiscoverAsync(MTConnectDriver driver)
+ {
+ var builder = new CapturingBuilder();
+ await driver.DiscoverAsync(builder, TestContext.Current.CancellationToken);
+
+ return builder;
+ }
+
+ // ---- the plan's headline test ----
+
+ ///
+ /// The whole contract in one place: the fixture's Device → Controller → Path tree reaches the
+ /// builder, the CONDITION data item is present under its own id, it types as
+ /// , and the driver declares the browse opt-in the
+ /// universal browser gates on.
+ ///
+ [Fact]
+ public async Task Discover_streams_device_component_dataitem_tree_leaf_fullname_is_dataitemid()
+ {
+ var (driver, _) = await InitializedDriverAsync();
+
+ var cap = await DiscoverAsync(driver);
+
+ cap.Variables.Select(v => v.Attr.FullName).ShouldContain("dev1_system_cond");
+ cap.Variables.Single(v => v.Attr.IsAlarm).Attr.DriverDataType.ShouldBe(DriverDataType.String);
+ driver.SupportsOnlineDiscovery.ShouldBeTrue();
+ driver.RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.Once);
+ }
+
+ // ---- tree shape ----
+
+ ///
+ /// The folder tree mirrors the probe document exactly: one folder per Device (named by its
+ /// name), then one per nested Component to arbitrary depth, each under its own parent.
+ ///
+ [Fact]
+ public async Task Discover_materialises_a_folder_per_device_and_nested_component()
+ {
+ var (driver, _) = await InitializedDriverAsync();
+
+ var cap = await DiscoverAsync(driver);
+
+ cap.Folders.Select(f => f.Path).ShouldBe(
+ ["VMC-3Axis", "VMC-3Axis/controller", "VMC-3Axis/controller/path"], ignoreOrder: true);
+
+ // Parentage, not just membership: a flat "three folders exist" assertion passes for a walker
+ // that streamed all three onto the root builder.
+ cap.Folders.Single(f => f.Path == "VMC-3Axis").ParentPath.ShouldBe(string.Empty);
+ cap.Folders.Single(f => f.Path == "VMC-3Axis/controller").ParentPath.ShouldBe("VMC-3Axis");
+ cap.Folders.Single(f => f.Path == "VMC-3Axis/controller/path").ParentPath.ShouldBe("VMC-3Axis/controller");
+ }
+
+ ///
+ /// dev1_avail hangs directly off <Device><DataItems>, outside every
+ /// Component. A walker that only recursed <Components> would drop it silently, so
+ /// it is asserted to land in the device folder while the Path-owned items land in the
+ /// path folder.
+ ///
+ [Fact]
+ public async Task Discover_places_device_level_data_items_in_the_device_folder()
+ {
+ var (driver, _) = await InitializedDriverAsync();
+
+ var cap = await DiscoverAsync(driver);
+
+ cap.Variables.Single(v => v.Attr.FullName == "dev1_avail").ParentPath.ShouldBe("VMC-3Axis");
+ cap.Variables
+ .Where(v => v.Attr.FullName != "dev1_avail")
+ .ShouldAllBe(v => v.ParentPath == "VMC-3Axis/controller/path");
+ }
+
+ ///
+ /// Every DataItem the fixture declares is streamed exactly once, and every leaf's
+ /// is its DataItem@id — the read/subscribe
+ /// key, aligned with the browser's commit by construction.
+ ///
+ [Fact]
+ public async Task Discover_streams_every_data_item_once_with_fullname_equal_to_its_id()
+ {
+ var (driver, _) = await InitializedDriverAsync();
+
+ var cap = await DiscoverAsync(driver);
+
+ cap.Variables.Count.ShouldBe(FixtureDataItemIds.Length);
+ cap.Variables.Select(v => v.Attr.FullName).ShouldBe(FixtureDataItemIds, ignoreOrder: true);
+ }
+
+ ///
+ /// The FullName-is-the-id rule, asserted where the two can actually differ.
+ ///
+ ///
+ /// The fixture cannot prove this on its own — its data items declare no name, so
+ /// browseName and id coincide and a driver that streamed the browse name as the
+ /// FullName would pass every fixture-based assertion in this file. This model gives
+ /// every data item a name that is disjoint from its id, so the two sets cannot be confused:
+ /// the commit key must be the id set, and the presentation must be the name set.
+ ///
+ [Fact]
+ public async Task Discover_fullname_is_the_id_even_when_the_data_item_declares_a_different_name()
+ {
+ var probe = ModelWith(
+ new MTConnectDataItem("id_pos", "Xpos", "SAMPLE", "POSITION", null, "MILLIMETER", null, null),
+ new MTConnectDataItem("id_count", "PartsMade", "EVENT", "PART_COUNT", null, null, null, null),
+ new MTConnectDataItem("id_cond", "SystemHealth", "CONDITION", "SYSTEM", null, null, null, null),
+ new MTConnectDataItem(
+ "id_series", "Feed", "SAMPLE", "PATH_FEEDRATE", null, null, "TIME_SERIES", 10));
+
+ var (driver, _) = await InitializedDriverAsync(probe: probe);
+
+ var cap = await DiscoverAsync(driver);
+
+ cap.Variables.Select(v => v.Attr.FullName)
+ .ShouldBe(["id_pos", "id_count", "id_cond", "id_series"], ignoreOrder: true);
+ cap.Variables.Select(v => v.BrowseName)
+ .ShouldBe(["Xpos", "PartsMade", "SystemHealth", "Feed"], ignoreOrder: true);
+
+ // …and the id-keyed metadata still lands on the right leaf once the two differ.
+ cap.Variables.Single(v => v.Attr.FullName == "id_count").Attr.DriverDataType
+ .ShouldBe(DriverDataType.Int64);
+ cap.Variables.Single(v => v.Attr.IsAlarm).Attr.FullName.ShouldBe("id_cond");
+ cap.Variables.Single(v => v.Attr.IsArray).Attr.FullName.ShouldBe("id_series");
+ }
+
+ // ---- type shape (must equal MTConnectDataTypeInference, not a local recomputation) ----
+
+ ///
+ /// The per-category defaults reach the builder unchanged: SAMPLE ⇒ Float64, controlled
+ /// vocabulary / free-text EVENT ⇒ String, CONDITION ⇒ String.
+ ///
+ [Theory]
+ [InlineData("dev1_pos", DriverDataType.Float64)]
+ [InlineData("dev1_avail", DriverDataType.String)]
+ [InlineData("dev1_execution", DriverDataType.String)]
+ [InlineData("dev1_program", DriverDataType.String)]
+ [InlineData("dev1_system_cond", DriverDataType.String)]
+ public async Task Discover_stamps_the_inferred_data_type(string dataItemId, DriverDataType expected)
+ {
+ var (driver, _) = await InitializedDriverAsync();
+
+ var cap = await DiscoverAsync(driver);
+
+ cap.Variables.Single(v => v.Attr.FullName == dataItemId).Attr.DriverDataType.ShouldBe(expected);
+ }
+
+ ///
+ /// PART_COUNT is the live-only bug this driver's inference was already fixed for once:
+ /// the probe document spells it UPPER_SNAKE, so a match written against the Streams
+ /// document's PartCount types every real agent's part counter as
+ /// while a PascalCase unit test stays green. Asserted at
+ /// the discovery seam because that is where the probe spelling is actually read.
+ ///
+ [Fact]
+ public async Task Discover_types_the_upper_snake_PART_COUNT_event_as_Int64()
+ {
+ var (driver, _) = await InitializedDriverAsync();
+
+ var cap = await DiscoverAsync(driver);
+
+ cap.Variables.Single(v => v.Attr.FullName == "dev1_partcount").Attr.DriverDataType
+ .ShouldBe(DriverDataType.Int64);
+ }
+
+ ///
+ /// A TIME_SERIES SAMPLE is an array whose length is the probe-declared
+ /// sampleCount. The shape comes from 's
+ /// returned record — recomputing it inline at this seam is what makes discovery and the
+ /// AdminUI editor disagree.
+ ///
+ [Fact]
+ public async Task Discover_carries_the_time_series_array_shape_from_the_inference()
+ {
+ var (driver, _) = await InitializedDriverAsync();
+
+ var cap = await DiscoverAsync(driver);
+
+ var series = cap.Variables.Single(v => v.Attr.FullName == "dev1_vibration_ts").Attr;
+ series.DriverDataType.ShouldBe(DriverDataType.Float64);
+ series.IsArray.ShouldBeTrue();
+ series.ArrayDim.ShouldBe(10u);
+ }
+
+ ///
+ /// A DATA_SET SAMPLE demotes to and stays scalar —
+ /// a rule that lives only inside the inference. An inline
+ /// "IsArray = representation == TIME_SERIES, DataType = category == SAMPLE ? Float64 : String"
+ /// at this seam would type it Float64 and go Bad on every parse, and no fixture-only test
+ /// would notice.
+ ///
+ [Fact]
+ public async Task Discover_demotes_a_data_set_sample_to_string_per_the_inference()
+ {
+ var probe = ModelWith(
+ new MTConnectDataItem(
+ "dev9_toolset", null, "SAMPLE", "POSITION", null, "MILLIMETER", "DATA_SET", 4));
+
+ var (driver, _) = await InitializedDriverAsync(probe: probe);
+
+ var cap = await DiscoverAsync(driver);
+
+ var attr = cap.Variables.Single(v => v.Attr.FullName == "dev9_toolset").Attr;
+ attr.DriverDataType.ShouldBe(DriverDataType.String);
+ attr.IsArray.ShouldBeFalse();
+ attr.ArrayDim.ShouldBeNull();
+ }
+
+ ///
+ /// TIME_SERIES is defined for SAMPLE only; on an EVENT the inference ignores it. Pins
+ /// the other half of "use the inference's answer, do not recompute the array shape".
+ ///
+ [Fact]
+ public async Task Discover_ignores_time_series_on_a_non_sample_per_the_inference()
+ {
+ var probe = ModelWith(
+ new MTConnectDataItem("dev9_msg", null, "EVENT", "MESSAGE", null, null, "TIME_SERIES", 8));
+
+ var (driver, _) = await InitializedDriverAsync(probe: probe);
+
+ var cap = await DiscoverAsync(driver);
+
+ var attr = cap.Variables.Single(v => v.Attr.FullName == "dev9_msg").Attr;
+ attr.DriverDataType.ShouldBe(DriverDataType.String);
+ attr.IsArray.ShouldBeFalse();
+ attr.ArrayDim.ShouldBeNull();
+ }
+
+ // ---- the flags this seam owns ----
+
+ ///
+ /// is the caller's job — the inference deliberately
+ /// does not compute it. Exactly the CONDITION data item carries it.
+ ///
+ [Fact]
+ public async Task Discover_marks_only_the_condition_data_item_as_an_alarm()
+ {
+ var (driver, _) = await InitializedDriverAsync();
+
+ var cap = await DiscoverAsync(driver);
+
+ cap.Variables.Where(v => v.Attr.IsAlarm).Select(v => v.Attr.FullName).ShouldBe(["dev1_system_cond"]);
+ }
+
+ ///
+ /// MTConnect is a read-only protocol and this driver implements no IWritable, so every
+ /// browsed leaf is — the tier that is read-only
+ /// from OPC UA. Nothing is historized by discovery either: historization is authored per tag,
+ /// never inferred from a device model.
+ ///
+ [Fact]
+ public async Task Discover_streams_every_leaf_view_only_and_not_historized()
+ {
+ var (driver, _) = await InitializedDriverAsync();
+
+ var cap = await DiscoverAsync(driver);
+
+ cap.Variables.ShouldAllBe(v => v.Attr.SecurityClass == SecurityClassification.ViewOnly);
+ cap.Variables.ShouldAllBe(v => !v.Attr.IsHistorized);
+ cap.Variables.ShouldAllBe(v => v.Attr.Source == NodeSourceKind.Driver);
+ }
+
+ // ---- browse names ----
+
+ ///
+ /// The browse name is the DataItem's name when it declares one and its id
+ /// otherwise — the fixture's data items declare none, so the fallback is the normal case for
+ /// this protocol, not an edge case.
+ ///
+ [Fact]
+ public async Task Discover_browse_name_prefers_the_data_item_name_and_falls_back_to_its_id()
+ {
+ var probe = ModelWith(
+ new MTConnectDataItem("dev9_named", "Xpos", "SAMPLE", "POSITION", null, "MILLIMETER", null, null),
+ new MTConnectDataItem("dev9_unnamed", null, "EVENT", "PROGRAM", null, null, null, null));
+
+ var (driver, _) = await InitializedDriverAsync(probe: probe);
+
+ var cap = await DiscoverAsync(driver);
+
+ var named = cap.Variables.Single(v => v.Attr.FullName == "dev9_named");
+ named.BrowseName.ShouldBe("Xpos");
+ named.DisplayName.ShouldBe("Xpos");
+
+ var unnamed = cap.Variables.Single(v => v.Attr.FullName == "dev9_unnamed");
+ unnamed.BrowseName.ShouldBe("dev9_unnamed");
+
+ // The browse name is presentation; the read key is not. Restated here because this is the
+ // one test where the two legitimately differ.
+ named.Attr.FullName.ShouldBe("dev9_named");
+ }
+
+ ///
+ /// A Device or Component with no name attribute falls back to its id rather
+ /// than materialising a blank folder.
+ ///
+ [Fact]
+ public async Task Discover_folder_names_fall_back_to_the_id_when_no_name_is_declared()
+ {
+ var probe = new MTConnectProbeModel(
+ [
+ new MTConnectDevice(
+ "dev9",
+ null,
+ [
+ new MTConnectComponent(
+ "dev9_axes",
+ null,
+ [],
+ [new MTConnectDataItem("dev9_x", null, "SAMPLE", "POSITION", null, null, null, null)]),
+ ],
+ []),
+ ]);
+
+ var (driver, _) = await InitializedDriverAsync(probe: probe);
+
+ var cap = await DiscoverAsync(driver);
+
+ cap.Folders.Select(f => f.Path).ShouldBe(["dev9", "dev9/dev9_axes"], ignoreOrder: true);
+ cap.Variables.Single().ParentPath.ShouldBe("dev9/dev9_axes");
+ }
+
+ // ---- DeviceName scoping ----
+
+ ///
+ /// With no DeviceName the driver is agent-wide and every device's subtree is streamed.
+ /// The control for the scoping test below — without it, a discovery that dropped the second
+ /// device for an unrelated reason would look like correct scoping.
+ ///
+ [Fact]
+ public async Task Discover_without_a_device_scope_streams_every_device()
+ {
+ var (driver, _) = await InitializedDriverAsync(probe: TwoDeviceModel());
+
+ var cap = await DiscoverAsync(driver);
+
+ cap.Folders.Where(f => f.ParentPath.Length == 0).Select(f => f.BrowseName)
+ .ShouldBe(["VMC-3Axis", "Lathe-2Axis"], ignoreOrder: true);
+ cap.Variables.Select(v => v.Attr.FullName).ShouldContain("dev2_avail");
+ }
+
+ ///
+ /// With DeviceName set, only that device's subtree is streamed.
+ ///
+ ///
+ /// Belt-and-braces rather than dead code: the production client already narrows the request
+ /// to {AgentUri}/{DeviceName}/probe, so a conforming Agent answers with one device
+ /// anyway. But a non-conforming Agent (or a proxy that drops the path segment) that answered
+ /// agent-wide would otherwise publish every other machine's data items into an instance
+ /// scoped to one — and the operator's only evidence would be a picker full of ids they never
+ /// configured.
+ ///
+ [Fact]
+ public async Task Discover_with_a_device_scope_streams_only_that_device_subtree()
+ {
+ var (driver, _) = await InitializedDriverAsync(Opts(deviceName: "Lathe-2Axis"), TwoDeviceModel());
+
+ var cap = await DiscoverAsync(driver);
+
+ cap.Folders.Select(f => f.Path).ShouldBe(["Lathe-2Axis"]);
+ cap.Variables.Select(v => v.Attr.FullName).ShouldBe(["dev2_avail"]);
+ }
+
+ ///
+ /// A device scope naming a device the Agent does not declare streams nothing. Kept explicit
+ /// because the alternative reading ("scope did not match, so stream everything") would hand a
+ /// mis-typed device name a picker showing the whole plant.
+ ///
+ [Fact]
+ public async Task Discover_with_an_unmatched_device_scope_streams_nothing()
+ {
+ var (driver, _) = await InitializedDriverAsync(Opts(deviceName: "not-a-device"), TwoDeviceModel());
+
+ var cap = await DiscoverAsync(driver);
+
+ cap.Folders.ShouldBeEmpty();
+ cap.Variables.ShouldBeEmpty();
+ }
+
+ ///
+ /// A device with no name is addressable by its id — which is what a scoped
+ /// Agent URL would carry for it too.
+ ///
+ [Fact]
+ public async Task Discover_device_scope_matches_a_nameless_device_by_id()
+ {
+ var probe = new MTConnectProbeModel(
+ [
+ new MTConnectDevice(
+ "dev9", null, [], [new MTConnectDataItem("dev9_a", null, "EVENT", "PROGRAM", null, null, null, null)]),
+ new MTConnectDevice(
+ "dev8", "Other", [], [new MTConnectDataItem("dev8_a", null, "EVENT", "PROGRAM", null, null, null, null)]),
+ ]);
+
+ var (driver, _) = await InitializedDriverAsync(Opts(deviceName: "dev9"), probe);
+
+ var cap = await DiscoverAsync(driver);
+
+ cap.Variables.Select(v => v.Attr.FullName).ShouldBe(["dev9_a"]);
+ }
+
+ ///
+ /// A configured DeviceName that matches nothing is an authoring error whose only
+ /// symptom is an empty picker. It must be reported at Warning — at Debug the operator
+ /// sees a browse that "worked" and a machine that apparently declares nothing, with no
+ /// evidence pointing at their typo.
+ ///
+ [Fact]
+ public async Task Discover_warns_when_the_configured_device_scope_matches_nothing()
+ {
+ var logger = new RecordingDriverLogger();
+ var (driver, _) = await InitializedDriverAsync(Opts(deviceName: "no-such-device"), logger: logger);
+
+ var cap = await DiscoverAsync(driver);
+
+ cap.Variables.ShouldBeEmpty();
+ logger.WarningsSnapshot()
+ .ShouldContain(w => w.Contains("no-such-device", StringComparison.Ordinal));
+ }
+
+ ///
+ /// …and an agent-wide browse that finds nothing is NOT that warning: it is a different
+ /// statement (the Agent declares no devices) and must not cry scope-typo.
+ ///
+ [Fact]
+ public async Task Discover_does_not_warn_about_scope_when_no_scope_is_configured()
+ {
+ var logger = new RecordingDriverLogger();
+ var (driver, _) = await InitializedDriverAsync(probe: new MTConnectProbeModel([]), logger: logger);
+
+ var cap = await DiscoverAsync(driver);
+
+ cap.Variables.ShouldBeEmpty();
+ logger.WarningsSnapshot().ShouldBeEmpty();
+ }
+
+ ///
+ /// A component declaring neither a name nor an id has no browse path at all,
+ /// and folding it in would open a folder called "" — a blank segment in the path of
+ /// everything beneath it. It is skipped; its siblings are unaffected. (A component with a
+ /// blank id but a real name is fine — the id is only the fallback.)
+ ///
+ [Fact]
+ public async Task Discover_skips_a_component_with_neither_a_name_nor_an_id()
+ {
+ var item = new MTConnectDataItem("dev9_x", null, "SAMPLE", "POSITION", null, null, null, null);
+ var probe = new MTConnectProbeModel(
+ [
+ new MTConnectDevice(
+ "dev9",
+ "VMC",
+ [
+ new MTConnectComponent(" ", " ", [], [item]),
+ new MTConnectComponent(" ", "Axes", [], [item]),
+ new MTConnectComponent("dev9_ctrl", null, [], [item]),
+ ],
+ []),
+ ]);
+
+ var (driver, _) = await InitializedDriverAsync(probe: probe);
+
+ var cap = await DiscoverAsync(driver);
+
+ cap.Folders.Select(f => f.Path).ShouldBe(["VMC", "VMC/Axes", "VMC/dev9_ctrl"], ignoreOrder: true);
+ cap.Folders.ShouldAllBe(f => !f.Path.Contains("//", StringComparison.Ordinal));
+ }
+
+ /// The same rule one level up: a device with no browse path is skipped, and said so.
+ [Fact]
+ public async Task Discover_skips_a_device_with_neither_a_name_nor_an_id()
+ {
+ var item = new MTConnectDataItem("dev9_x", null, "SAMPLE", "POSITION", null, null, null, null);
+ var logger = new RecordingDriverLogger();
+ var probe = new MTConnectProbeModel(
+ [
+ new MTConnectDevice(" ", " ", [], [item]),
+ new MTConnectDevice("dev9", "VMC", [], [item]),
+ ]);
+
+ var (driver, _) = await InitializedDriverAsync(probe: probe, logger: logger);
+
+ var cap = await DiscoverAsync(driver);
+
+ cap.Folders.Select(f => f.Path).ShouldBe(["VMC"]);
+ logger.WarningsSnapshot()
+ .ShouldContain(w => w.Contains("neither a name nor an id", StringComparison.Ordinal));
+ }
+
+ // ---- the probe-cache contract (anti-wedge) ----
+
+ ///
+ /// Discovery is served from the cache InitializeAsync already populated — a browse
+ /// does not re-hit the Agent for a model it is holding.
+ ///
+ [Fact]
+ public async Task Discover_serves_from_the_cached_probe_model()
+ {
+ var (driver, client) = await InitializedDriverAsync();
+ client.ProbeCallCount.ShouldBe(1);
+
+ await DiscoverAsync(driver);
+
+ client.ProbeCallCount.ShouldBe(1);
+ }
+
+ ///
+ /// …and after has dropped that cache,
+ /// discovery re-fetches and still streams the full tree. This is the whole reason discovery
+ /// must go through GetOrFetchProbeModelAsync rather than reading the cached field: a
+ /// memory-budget flush must never leave browse permanently answering "this agent has no data
+ /// items".
+ ///
+ [Fact]
+ public async Task Discover_refetches_the_probe_model_after_a_cache_flush()
+ {
+ var (driver, client) = await InitializedDriverAsync();
+ await driver.FlushOptionalCachesAsync(TestContext.Current.CancellationToken);
+ driver.CachedProbeModel.ShouldBeNull();
+
+ var cap = await DiscoverAsync(driver);
+
+ client.ProbeCallCount.ShouldBe(2);
+ cap.Variables.Select(v => v.Attr.FullName).ShouldBe(FixtureDataItemIds, ignoreOrder: true);
+ driver.CachedProbeModel.ShouldNotBeNull();
+ }
+
+ // ---- discovery never touches the subscription surface ----
+
+ ///
+ /// Discovery is a pure read of the device model: it does not write the shared observation
+ /// index (the /sample pump is its sole writer) and it does not issue a /current.
+ /// A discovery that folded a snapshot into the index would be a second writer racing the
+ /// pump, rolling subscribed values backwards.
+ ///
+ [Fact]
+ public async Task Discover_does_not_touch_the_observation_index_or_call_current()
+ {
+ var options = new MTConnectDriverOptions
+ {
+ AgentUri = AgentUri,
+ Tags = [new MTConnectTagDefinition("dev1_pos", DriverDataType.Float64)],
+ };
+
+ var (driver, client) = await InitializedDriverAsync(options);
+ var index = driver.ObservationIndex;
+ var indexed = index.Count;
+ var currentCalls = client.CurrentCallCount;
+
+ await DiscoverAsync(driver);
+
+ ReferenceEquals(driver.ObservationIndex, index).ShouldBeTrue();
+ driver.ObservationIndex.Count.ShouldBe(indexed);
+ client.CurrentCallCount.ShouldBe(currentCalls);
+ }
+
+ // ---- failure posture: loud, never a silently-empty tree ----
+
+ ///
+ /// Discovery before InitializeAsync throws rather than streaming an empty tree.
+ ///
+ ///
+ /// This is the deliberate opposite of 's
+ /// never-throw posture, and the difference is what the caller can do with the answer. A
+ /// read has per-reference Bad status codes to report a failure in-band; a discovery
+ /// stream has no such channel — an empty tree is indistinguishable from "this Agent declares
+ /// no data items", which is the #485 shape (failure wearing success's clothes) and would read
+ /// to an operator in the browse picker as a working connection to an empty machine. Both
+ /// production callers handle the throw: the universal browser surfaces it as a failed browse,
+ /// and DriverInstanceActor logs it, publishes an empty node set, and does NOT retry
+ /// (retrying is an UntilStable behaviour; this driver is Once) — which today
+ /// reaches nothing anyway, since DriverHostActor's discovered-node injection is
+ /// dormant in v3. The browse path is the consumer this posture is chosen for.
+ ///
+ [Fact]
+ public async Task Discover_before_initialize_throws_and_streams_nothing()
+ {
+ var (driver, client) = NewDriver();
+ var builder = new CapturingBuilder();
+
+ await Should.ThrowAsync(
+ () => driver.DiscoverAsync(builder, TestContext.Current.CancellationToken));
+
+ builder.Folders.ShouldBeEmpty();
+ builder.Variables.ShouldBeEmpty();
+ client.ProbeCallCount.ShouldBe(0);
+ }
+
+ /// Same posture after ShutdownAsync — a stopped driver has no model to browse.
+ [Fact]
+ public async Task Discover_after_shutdown_throws_and_streams_nothing()
+ {
+ var (driver, _) = await InitializedDriverAsync();
+ await driver.ShutdownAsync(TestContext.Current.CancellationToken);
+
+ var builder = new CapturingBuilder();
+
+ await Should.ThrowAsync(
+ () => driver.DiscoverAsync(builder, TestContext.Current.CancellationToken));
+
+ builder.Variables.ShouldBeEmpty();
+ }
+
+ ///
+ /// An Agent that fails the /probe a discovery needs fails the discovery — it does not
+ /// degrade to an empty tree. Same #485 reasoning as the un-initialized case.
+ ///
+ [Fact]
+ public async Task Discover_propagates_an_agent_probe_failure_rather_than_streaming_an_empty_tree()
+ {
+ var (driver, client) = await InitializedDriverAsync();
+ await driver.FlushOptionalCachesAsync(TestContext.Current.CancellationToken);
+ client.ProbeFailure = new HttpRequestException("agent down");
+
+ var builder = new CapturingBuilder();
+
+ await Should.ThrowAsync(
+ () => driver.DiscoverAsync(builder, TestContext.Current.CancellationToken));
+
+ builder.Variables.ShouldBeEmpty();
+ }
+
+ /// Caller cancellation propagates, as it does on every other seam of this driver.
+ [Fact]
+ public async Task Discover_propagates_caller_cancellation()
+ {
+ var (driver, _) = await InitializedDriverAsync();
+ await driver.FlushOptionalCachesAsync(TestContext.Current.CancellationToken);
+
+ using var cts = new CancellationTokenSource();
+ await cts.CancelAsync();
+
+ await Should.ThrowAsync(
+ () => driver.DiscoverAsync(new CapturingBuilder(), cts.Token));
+ }
+
+ /// A null builder is a caller bug — the one thing this method is loud about up front.
+ [Fact]
+ public async Task Discover_with_a_null_builder_throws_ArgumentNullException()
+ {
+ var (driver, _) = await InitializedDriverAsync();
+
+ await Should.ThrowAsync(
+ () => driver.DiscoverAsync(null!, TestContext.Current.CancellationToken));
+ }
+
+ // ---- the browse opt-in ----
+
+ ///
+ /// The universal browser's CanBrowse constructs a throwaway instance purely to read
+ /// these two members, so they must answer on an un-initialized driver without
+ /// dialling anything.
+ ///
+ [Fact]
+ public void Browse_opt_in_answers_on_an_uninitialized_instance_without_connecting()
+ {
+ var (driver, client) = NewDriver();
+
+ driver.ShouldBeAssignableTo();
+ driver.SupportsOnlineDiscovery.ShouldBeTrue();
+ driver.RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.Once);
+ client.ProbeCallCount.ShouldBe(0);
+ client.CurrentCallCount.ShouldBe(0);
+ }
+
+ // ---- helpers ----
+
+ /// A one-device model whose single Path component owns .
+ private static MTConnectProbeModel ModelWith(params MTConnectDataItem[] dataItems) =>
+ new(
+ [
+ new MTConnectDevice(
+ "dev9",
+ "Probe9",
+ [new MTConnectComponent("dev9_path", "path", [], dataItems)],
+ []),
+ ]);
+
+ ///
+ /// The canned fixture's device plus a second, independent one — the multi-device Agent the
+ /// DeviceName scope exists for.
+ ///
+ private static MTConnectProbeModel TwoDeviceModel()
+ {
+ var fixture = MTConnectProbeParser.Parse(File.ReadAllText("Fixtures/probe.xml"));
+ var second = new MTConnectDevice(
+ "dev2",
+ "Lathe-2Axis",
+ [],
+ [new MTConnectDataItem("dev2_avail", null, "EVENT", "AVAILABILITY", null, null, null, null)]);
+
+ return new MTConnectProbeModel([.. fixture.Devices, second]);
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverLifecycleTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverLifecycleTests.cs
new file mode 100644
index 00000000..197f429e
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverLifecycleTests.cs
@@ -0,0 +1,734 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
+
+///
+/// Task 9 — 's lifecycle over the
+/// seam: construction, initialize, reinitialize, shutdown,
+/// health, footprint, and cache flush. Every test runs against
+/// ; no socket is opened anywhere in this file.
+///
+///
+/// The load-bearing assertions here are the ones about what must NOT happen: a
+/// constructor that dials, an initialize that faults but still reports Healthy, a re-point that
+/// keeps talking to the old Agent, and a cache flush that wedges browse. Each of those is a
+/// defect the happy-path assertions alone would pass straight over.
+///
+public sealed class MTConnectDriverLifecycleTests
+{
+ private const string AgentUri = "http://fixture-agent:5000";
+
+ /// Every dataItemId the canned probe declares — the browse-cache footprint basis.
+ private const int FixtureDataItemCount = 7;
+
+ private static MTConnectDriverOptions Opts(
+ string agentUri = AgentUri,
+ string? deviceName = null,
+ int requestTimeoutMs = 5000,
+ int heartbeatMs = 10000,
+ int sampleIntervalMs = 1000,
+ int sampleCount = 1000) =>
+ new()
+ {
+ AgentUri = agentUri,
+ DeviceName = deviceName,
+ RequestTimeoutMs = requestTimeoutMs,
+ HeartbeatMs = heartbeatMs,
+ SampleIntervalMs = sampleIntervalMs,
+ SampleCount = sampleCount,
+ Tags =
+ [
+ new MTConnectTagDefinition("dev1_pos", DriverDataType.Float64),
+ new MTConnectTagDefinition("dev1_execution", DriverDataType.String),
+ ],
+ };
+
+ /// A driver wired to one canned client, plus that client, for the common case.
+ private static (MTConnectDriver Driver, CannedAgentClient Client) NewDriver(
+ MTConnectDriverOptions? options = null)
+ {
+ var client = CannedAgentClient.FromFixtures();
+
+ return (new MTConnectDriver(options ?? Opts(), "mt1", _ => client), client);
+ }
+
+ private static async Task<(MTConnectDriver Driver, CannedAgentClient Client)> InitializedDriverAsync()
+ {
+ var (driver, client) = NewDriver();
+ await driver.InitializeAsync("{}", CancellationToken.None);
+
+ return (driver, client);
+ }
+
+ // ---- connection-free construction ----
+
+ [Fact]
+ public void Constructor_dials_nothing_and_does_not_even_build_a_client()
+ {
+ var factoryCalls = 0;
+ var client = CannedAgentClient.FromFixtures();
+
+ var driver = new MTConnectDriver(Opts(), "mt1", _ =>
+ {
+ factoryCalls++;
+
+ return client;
+ });
+
+ // The universal discovery browser constructs a throwaway instance purely to ask CanBrowse.
+ // If the ctor built (or worse, dialled) a client, every browse probe would open a connection
+ // pool against a possibly-unreachable Agent.
+ factoryCalls.ShouldBe(0);
+ client.ProbeCallCount.ShouldBe(0);
+ client.CurrentCallCount.ShouldBe(0);
+ client.SampleCallCount.ShouldBe(0);
+ driver.GetHealth().State.ShouldBe(DriverState.Unknown);
+ }
+
+ [Fact]
+ public void Identity_is_the_constructor_arguments()
+ {
+ var (driver, _) = NewDriver();
+
+ driver.DriverInstanceId.ShouldBe("mt1");
+ driver.DriverType.ShouldBe("MTConnect");
+ }
+
+ // ---- initialize ----
+
+ [Fact]
+ public async Task Initialize_primes_index_and_reports_healthy()
+ {
+ var (driver, client) = NewDriver();
+
+ await driver.InitializeAsync("{}", CancellationToken.None);
+
+ driver.GetHealth().State.ShouldBe(DriverState.Healthy);
+ client.ProbeCallCount.ShouldBe(1);
+ client.CurrentCallCount.ShouldBe(1);
+
+ // The /current snapshot is what makes the driver able to answer a read at all.
+ driver.ObservationIndex.Count.ShouldBe(FixtureDataItemCount);
+ driver.ObservationIndex.Get("dev1_pos").StatusCode.ShouldBe(0u);
+ }
+
+ [Fact]
+ public async Task Initialize_records_the_agent_instance_id_and_the_probe_model()
+ {
+ var (driver, client) = NewDriver();
+
+ await driver.InitializeAsync("{}", CancellationToken.None);
+
+ driver.AgentInstanceId.ShouldBe(client.Current.InstanceId);
+ driver.CachedProbeModel.ShouldNotBeNull();
+ driver.CachedProbeModel!.Devices.Count.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task Initialize_stamps_last_successful_read_between_the_call_boundaries()
+ {
+ var (driver, _) = NewDriver();
+
+ var before = DateTime.UtcNow;
+ await driver.InitializeAsync("{}", CancellationToken.None);
+ var after = DateTime.UtcNow;
+
+ var lastRead = driver.GetHealth().LastSuccessfulRead;
+ lastRead.ShouldNotBeNull();
+ lastRead!.Value.ShouldBeGreaterThanOrEqualTo(before);
+ lastRead.Value.ShouldBeLessThanOrEqualTo(after);
+ }
+
+ [Fact]
+ public async Task Initialize_faults_and_rethrows_when_probe_fails()
+ {
+ var (driver, client) = NewDriver();
+ client.ProbeFailure = new HttpRequestException("agent unreachable");
+
+ var thrown = await Should.ThrowAsync(
+ () => driver.InitializeAsync("{}", CancellationToken.None));
+
+ thrown.Message.ShouldBe("agent unreachable");
+
+ var health = driver.GetHealth();
+ health.State.ShouldBe(DriverState.Faulted);
+ health.LastError.ShouldNotBeNull();
+ health.LastError!.ShouldContain("agent unreachable");
+
+ // No half-initialized object: the client it built must not survive the failure.
+ client.IsDisposed.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task Initialize_faults_when_probe_succeeds_but_the_priming_current_fails()
+ {
+ // Deliberate posture (documented on InitializeAsync): a driver whose /probe worked but whose
+ // priming /current did not has an EMPTY value index and no sample cursor. Reporting Healthy
+ // there would render every tag BadWaitingForInitialData forever while the dashboard shows a
+ // green driver — the #485 "a failure that looks like success" shape.
+ var (driver, client) = NewDriver();
+ client.CurrentFailure = new InvalidDataException("current unparseable");
+
+ await Should.ThrowAsync(
+ () => driver.InitializeAsync("{}", CancellationToken.None));
+
+ client.ProbeCallCount.ShouldBe(1);
+ driver.GetHealth().State.ShouldBe(DriverState.Faulted);
+ driver.GetHealth().State.ShouldNotBe(DriverState.Healthy);
+ driver.CachedProbeModel.ShouldBeNull();
+ driver.ObservationIndex.Count.ShouldBe(0);
+ client.IsDisposed.ShouldBeTrue();
+ }
+
+ ///
+ /// arch-review 01/S-6: an operator-authorable 0 must never come to mean "wait
+ /// forever" (or "ask the Agent for nothing"). All four knobs share one
+ /// RequirePositive path, so all four are asserted — a guard that covered only the
+ /// one knob with a test is exactly how three of them would quietly lose the check.
+ ///
+ ///
+ /// Asserted through a FAKE client factory, so this proves the DRIVER validates rather than
+ /// leaning on the production client's own constructor guard.
+ ///
+ [Theory]
+ [InlineData("RequestTimeoutMs")]
+ [InlineData("HeartbeatMs")]
+ [InlineData("SampleIntervalMs")]
+ [InlineData("SampleCount")]
+ public async Task Initialize_rejects_a_non_positive_timing_knob_before_dialling(string knob)
+ {
+ var options = knob switch
+ {
+ "RequestTimeoutMs" => Opts(requestTimeoutMs: 0),
+ "HeartbeatMs" => Opts(heartbeatMs: 0),
+ "SampleIntervalMs" => Opts(sampleIntervalMs: 0),
+ "SampleCount" => Opts(sampleCount: 0),
+ _ => throw new ArgumentOutOfRangeException(nameof(knob), knob, "unhandled knob"),
+ };
+
+ var factoryCalls = 0;
+ var driver = new MTConnectDriver(options, "mt1", _ =>
+ {
+ factoryCalls++;
+
+ return CannedAgentClient.FromFixtures();
+ });
+
+ var thrown = await Should.ThrowAsync(
+ () => driver.InitializeAsync("{}", CancellationToken.None));
+
+ // The message must name the offending key, or an operator cannot act on it.
+ thrown.Message.ShouldContain(knob);
+ factoryCalls.ShouldBe(0);
+ driver.GetHealth().State.ShouldBe(DriverState.Faulted);
+ }
+
+ // ---- the driverConfigJson argument ----
+
+ [Fact]
+ public async Task Initialize_honours_the_config_json_over_the_constructor_options()
+ {
+ // The runtime delivers a config change ONLY through this argument (DriverInstanceActor
+ // ApplyDelta -> ReinitializeAsync(json) on the LIVE instance). Ignoring it would make every
+ // MTConnect config change a silent no-op that still seals the deployment green.
+ MTConnectDriverOptions? seen = null;
+ var driver = new MTConnectDriver(Opts(), "mt1", o =>
+ {
+ seen = o;
+
+ return CannedAgentClient.FromFixtures();
+ });
+
+ await driver.InitializeAsync(
+ """{"agentUri":"http://other-agent:5001","deviceName":"VMC-3Axis"}""",
+ CancellationToken.None);
+
+ seen.ShouldNotBeNull();
+ seen!.AgentUri.ShouldBe("http://other-agent:5001");
+ seen.DeviceName.ShouldBe("VMC-3Axis");
+ }
+
+ ///
+ /// "Semantically empty" is decided by parsing, not by matching the literal two-character
+ /// spellings. A pretty-printer, a formatter, or a hand edit turns {} into { }
+ /// or {\n} without changing its meaning — and under literal matching each of those
+ /// reached ParseOptions, failed the required-AgentUri check, and turned an empty document
+ /// into a cold-start fault or a rejected deployment.
+ ///
+ [Theory]
+ [InlineData("{}")]
+ [InlineData("{ }")]
+ [InlineData("{\n}")]
+ [InlineData(" {\r\n\t} ")]
+ [InlineData("[]")]
+ [InlineData("[ ]")]
+ [InlineData("null")]
+ [InlineData("")]
+ [InlineData(" ")]
+ public async Task Initialize_with_a_semantically_empty_config_body_keeps_the_constructor_options(string json)
+ {
+ MTConnectDriverOptions? seen = null;
+ var driver = new MTConnectDriver(Opts(), "mt1", o =>
+ {
+ seen = o;
+
+ return CannedAgentClient.FromFixtures();
+ });
+
+ await driver.InitializeAsync(json, CancellationToken.None);
+
+ seen.ShouldNotBeNull();
+ seen!.AgentUri.ShouldBe(AgentUri);
+ seen.Tags.Count.ShouldBe(2);
+ driver.GetHealth().State.ShouldBe(DriverState.Healthy);
+ }
+
+ [Fact]
+ public async Task Initialize_treats_a_malformed_config_body_as_a_parse_error_not_as_empty()
+ {
+ // The other half of the emptiness rule: unreadable text must NOT fall through to "no config
+ // supplied" and silently start the driver on stale options.
+ var (driver, _) = NewDriver();
+
+ var thrown = await Should.ThrowAsync(
+ () => driver.InitializeAsync("""{"agentUri": """, CancellationToken.None));
+
+ thrown.Message.ShouldContain("not valid JSON");
+ driver.GetHealth().State.ShouldBe(DriverState.Faulted);
+ }
+
+ [Fact]
+ public async Task Initialize_faults_on_a_config_body_with_no_agent_uri()
+ {
+ var (driver, _) = NewDriver();
+
+ await Should.ThrowAsync(
+ () => driver.InitializeAsync("""{"deviceName":"VMC-3Axis"}""", CancellationToken.None));
+
+ driver.GetHealth().State.ShouldBe(DriverState.Faulted);
+ }
+
+ [Fact]
+ public async Task Initialize_on_a_live_instance_with_an_unreadable_config_keeps_it_serving()
+ {
+ // One rule for both entry points: an unreadable document never destroys working state; it
+ // faults only a driver that had none. InitializeAsync IS reachable on a live instance
+ // (DriverInstanceActor re-initialises during Connecting/Reconnecting), so parsing must
+ // happen BEFORE the teardown — otherwise a bad edit kills a healthy client and the driver
+ // ends up in exactly the state ReinitializeAsync is written to avoid.
+ var (driver, client) = await InitializedDriverAsync();
+
+ await Should.ThrowAsync(
+ () => driver.InitializeAsync("""{"deviceName":"no-agent-uri"}""", CancellationToken.None));
+
+ driver.GetHealth().State.ShouldBe(DriverState.Healthy);
+ driver.EffectiveOptions.AgentUri.ShouldBe(AgentUri);
+ client.IsDisposed.ShouldBeFalse();
+ driver.ObservationIndex.Count.ShouldBe(FixtureDataItemCount);
+ }
+
+ [Fact]
+ public async Task Initialize_parses_authored_tags_out_of_the_config_json()
+ {
+ MTConnectDriverOptions? seen = null;
+ var driver = new MTConnectDriver(Opts(), "mt1", o =>
+ {
+ seen = o;
+
+ return CannedAgentClient.FromFixtures();
+ });
+
+ await driver.InitializeAsync(
+ """
+ {"agentUri":"http://a:5000",
+ "tags":[{"fullName":"dev1_partcount","driverDataType":"Int32"}]}
+ """,
+ CancellationToken.None);
+
+ seen.ShouldNotBeNull();
+ seen!.Tags.Count.ShouldBe(1);
+ seen.Tags[0].FullName.ShouldBe("dev1_partcount");
+
+ // Enum-serialization trap (project-wide MEMORY): enum-carrying config fields are NAMES.
+ seen.Tags[0].DriverDataType.ShouldBe(DriverDataType.Int32);
+ }
+
+ // ---- reinitialize ----
+
+ [Fact]
+ public async Task Reinitialize_with_unchanged_endpoint_config_keeps_the_same_client()
+ {
+ var built = new List();
+ var driver = new MTConnectDriver(Opts(), "mt1", _ =>
+ {
+ var c = CannedAgentClient.FromFixtures();
+ built.Add(c);
+
+ return c;
+ });
+
+ await driver.InitializeAsync("{}", CancellationToken.None);
+ await driver.ReinitializeAsync(
+ $$"""{"agentUri":"{{AgentUri}}","tags":[{"fullName":"dev1_pos","driverDataType":"Float64"}]}""",
+ CancellationToken.None);
+
+ // Config-only: the live connection pool survives, so no client churn...
+ built.Count.ShouldBe(1);
+ built[0].IsDisposed.ShouldBeFalse();
+
+ // ...but everything DERIVED from config is genuinely rebuilt against the new document.
+ built[0].ProbeCallCount.ShouldBe(2);
+ built[0].CurrentCallCount.ShouldBe(2);
+ driver.GetHealth().State.ShouldBe(DriverState.Healthy);
+ }
+
+ [Fact]
+ public async Task Reinitialize_with_a_changed_agent_uri_tears_down_and_rebuilds_the_client()
+ {
+ var built = new List<(MTConnectDriverOptions Options, CannedAgentClient Client)>();
+ var driver = new MTConnectDriver(Opts(), "mt1", o =>
+ {
+ var c = CannedAgentClient.FromFixtures();
+ built.Add((o, c));
+
+ return c;
+ });
+
+ await driver.InitializeAsync("{}", CancellationToken.None);
+ await driver.ReinitializeAsync(
+ """{"agentUri":"http://relocated-agent:5000"}""", CancellationToken.None);
+
+ // A re-pointed driver that kept the old client would keep reading the OLD machine while the
+ // deployment reports success.
+ built.Count.ShouldBe(2);
+ built[0].Client.IsDisposed.ShouldBeTrue();
+ built[1].Options.AgentUri.ShouldBe("http://relocated-agent:5000");
+ built[1].Client.IsDisposed.ShouldBeFalse();
+ driver.GetHealth().State.ShouldBe(DriverState.Healthy);
+ }
+
+ [Fact]
+ public async Task Reinitialize_with_a_changed_device_scope_rebuilds_the_client()
+ {
+ var built = new List<(MTConnectDriverOptions Options, CannedAgentClient Client)>();
+ var driver = new MTConnectDriver(Opts(), "mt1", o =>
+ {
+ var c = CannedAgentClient.FromFixtures();
+ built.Add((o, c));
+
+ return c;
+ });
+
+ await driver.InitializeAsync("{}", CancellationToken.None);
+ await driver.ReinitializeAsync(
+ $$"""{"agentUri":"{{AgentUri}}","deviceName":"VMC-3Axis"}""", CancellationToken.None);
+
+ // DeviceName is baked into every request URI at client construction.
+ built.Count.ShouldBe(2);
+ built[0].Client.IsDisposed.ShouldBeTrue();
+ built[1].Options.DeviceName.ShouldBe("VMC-3Axis");
+ }
+
+ [Fact]
+ public async Task Reinitialize_with_changed_request_knobs_rebuilds_the_client()
+ {
+ // RequestTimeoutMs / HeartbeatMs / SampleIntervalMs / SampleCount are all read by the client
+ // CONSTRUCTOR (deadlines, watchdog window, /sample query string). Keeping the old client
+ // because the URI happened not to change would silently discard the operator's edit.
+ var built = new List<(MTConnectDriverOptions Options, CannedAgentClient Client)>();
+ var driver = new MTConnectDriver(Opts(), "mt1", o =>
+ {
+ var c = CannedAgentClient.FromFixtures();
+ built.Add((o, c));
+
+ return c;
+ });
+
+ await driver.InitializeAsync("{}", CancellationToken.None);
+ await driver.ReinitializeAsync(
+ $$"""{"agentUri":"{{AgentUri}}","heartbeatMs":2500}""", CancellationToken.None);
+
+ built.Count.ShouldBe(2);
+ built[1].Options.HeartbeatMs.ShouldBe(2500);
+ built[0].Client.IsDisposed.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task Reinitialize_before_initialize_behaves_as_initialize()
+ {
+ var (driver, client) = NewDriver();
+
+ await driver.ReinitializeAsync("{}", CancellationToken.None);
+
+ driver.GetHealth().State.ShouldBe(DriverState.Healthy);
+ client.ProbeCallCount.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task Reinitialize_that_fails_leaves_the_driver_faulted_not_healthy()
+ {
+ var built = new List();
+ var driver = new MTConnectDriver(Opts(), "mt1", _ =>
+ {
+ var c = CannedAgentClient.FromFixtures();
+ built.Add(c);
+
+ return c;
+ });
+
+ await driver.InitializeAsync("{}", CancellationToken.None);
+ built[0].CurrentFailure = new HttpRequestException("agent went away");
+
+ // Config-only shape (only the tag list changes), so the failing leg is the re-prime against
+ // the client that is already live — the path a rebuild would otherwise mask.
+ await Should.ThrowAsync(
+ () => driver.ReinitializeAsync(
+ $$"""{"agentUri":"{{AgentUri}}","tags":[{"fullName":"dev1_pos"}]}""", CancellationToken.None));
+
+ built.Count.ShouldBe(1);
+ driver.GetHealth().State.ShouldBe(DriverState.Faulted);
+ driver.GetHealth().LastError.ShouldNotBeNull().ShouldContain("agent went away");
+
+ // A failed refresh must not leave a live client nobody owns.
+ built[0].IsDisposed.ShouldBeTrue();
+ }
+
+ [Fact]
+ public async Task Reinitialize_with_an_unreadable_config_keeps_the_running_driver_serving()
+ {
+ // Blast radius: an unparseable config edit fails the DEPLOYMENT (ApplyResult(false)); it must
+ // not down a driver that is happily serving its previous, valid configuration.
+ var (driver, client) = await InitializedDriverAsync();
+
+ await Should.ThrowAsync(
+ () => driver.ReinitializeAsync("""{"deviceName":"no-agent-uri"}""", CancellationToken.None));
+
+ driver.GetHealth().State.ShouldBe(DriverState.Healthy);
+ driver.EffectiveOptions.AgentUri.ShouldBe(AgentUri);
+ client.IsDisposed.ShouldBeFalse();
+ driver.ObservationIndex.Count.ShouldBe(FixtureDataItemCount);
+ }
+
+ // ---- shutdown ----
+
+ [Fact]
+ public async Task Shutdown_disposes_the_client_and_drops_the_served_state()
+ {
+ var (driver, client) = await InitializedDriverAsync();
+ var lastRead = driver.GetHealth().LastSuccessfulRead;
+
+ await driver.ShutdownAsync(CancellationToken.None);
+
+ client.DisposeCount.ShouldBe(1);
+ driver.GetHealth().State.ShouldBe(DriverState.Unknown);
+
+ // Retained: it is diagnostic ("when did we last hear from the Agent"), not served data.
+ driver.GetHealth().LastSuccessfulRead.ShouldBe(lastRead);
+
+ // Dropped: values from a torn-down connection must never keep reading Good.
+ driver.ObservationIndex.Count.ShouldBe(0);
+ driver.CachedProbeModel.ShouldBeNull();
+ }
+
+ [Fact]
+ public async Task Shutdown_is_idempotent()
+ {
+ var (driver, client) = await InitializedDriverAsync();
+
+ await driver.ShutdownAsync(CancellationToken.None);
+ await driver.ShutdownAsync(CancellationToken.None);
+
+ client.DisposeCount.ShouldBe(1);
+ driver.GetHealth().State.ShouldBe(DriverState.Unknown);
+ }
+
+ [Fact]
+ public async Task Shutdown_before_initialize_is_a_no_op()
+ {
+ var (driver, client) = NewDriver();
+
+ await driver.ShutdownAsync(CancellationToken.None);
+
+ client.DisposeCount.ShouldBe(0);
+ driver.GetHealth().State.ShouldBe(DriverState.Unknown);
+ }
+
+ // ---- footprint + flush ----
+
+ [Fact]
+ public async Task Memory_footprint_grows_when_initialize_populates_the_caches()
+ {
+ var (driver, _) = NewDriver();
+
+ var before = driver.GetMemoryFootprint();
+ await driver.InitializeAsync("{}", CancellationToken.None);
+ var after = driver.GetMemoryFootprint();
+
+ // Before init the only config-attributable state is the two authored tags.
+ before.ShouldBeLessThan(after);
+ after.ShouldBeGreaterThan(0);
+ }
+
+ [Fact]
+ public async Task Flush_drops_the_probe_cache_and_keeps_the_observation_index()
+ {
+ var (driver, _) = await InitializedDriverAsync();
+ var before = driver.GetMemoryFootprint();
+
+ await driver.FlushOptionalCachesAsync(CancellationToken.None);
+
+ driver.GetMemoryFootprint().ShouldBeLessThan(before);
+ driver.CachedProbeModel.ShouldBeNull();
+
+ // The index is required for correctness (it IS the read surface) — never flushable.
+ driver.ObservationIndex.Count.ShouldBe(FixtureDataItemCount);
+ driver.ObservationIndex.Get("dev1_pos").StatusCode.ShouldBe(0u);
+ driver.GetHealth().State.ShouldBe(DriverState.Healthy);
+ }
+
+ [Fact]
+ public async Task Flush_does_not_wedge_the_driver_the_probe_model_is_refetched_on_demand()
+ {
+ // Task 12's DiscoverAsync reads the probe model. If flushing left no way back to it, a
+ // cache-budget breach would permanently disable browse on that driver instance.
+ var (driver, client) = await InitializedDriverAsync();
+ await driver.FlushOptionalCachesAsync(CancellationToken.None);
+
+ client.ProbeCallCount.ShouldBe(1);
+
+ var model = await driver.GetOrFetchProbeModelAsync(CancellationToken.None);
+
+ model.Devices.Count.ShouldBe(1);
+ client.ProbeCallCount.ShouldBe(2);
+ driver.CachedProbeModel.ShouldNotBeNull();
+ }
+
+ [Fact]
+ public async Task Probe_model_is_served_from_cache_while_it_is_warm()
+ {
+ var (driver, client) = await InitializedDriverAsync();
+
+ _ = await driver.GetOrFetchProbeModelAsync(CancellationToken.None);
+
+ client.ProbeCallCount.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task Fetching_the_probe_model_before_initialize_is_rejected_rather_than_silently_empty()
+ {
+ var (driver, _) = NewDriver();
+
+ await Should.ThrowAsync(
+ () => driver.GetOrFetchProbeModelAsync(CancellationToken.None));
+ }
+
+ [Fact]
+ public async Task Fetching_the_probe_model_after_shutdown_is_rejected()
+ {
+ var (driver, _) = await InitializedDriverAsync();
+ await driver.ShutdownAsync(CancellationToken.None);
+
+ await Should.ThrowAsync(
+ () => driver.GetOrFetchProbeModelAsync(CancellationToken.None));
+ }
+
+ // ---- lock-free session snapshot: the probe fetch races the lifecycle ----
+
+ [Fact]
+ public async Task A_shutdown_landing_mid_fetch_surfaces_as_not_connected_not_as_object_disposed()
+ {
+ // Browse is deliberately lock-free (it awaits the network; holding the lifecycle lock would
+ // let it stall a shutdown for a whole RequestTimeoutMs), so a fetch CAN outlive the client
+ // it captured. What must not happen is a raw ObjectDisposedException escaping into browse —
+ // the caller already handles "not connected" and should have exactly one failure mode.
+ var (driver, client) = await InitializedDriverAsync();
+ await driver.FlushOptionalCachesAsync(CancellationToken.None);
+
+ // Held locally: the gate is one-shot, so the client has already cleared its own reference by
+ // the time the request is parked on it.
+ var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ client.ProbeGate = gate;
+ var inFlight = driver.GetOrFetchProbeModelAsync(CancellationToken.None);
+
+ await driver.ShutdownAsync(CancellationToken.None);
+ client.IsDisposed.ShouldBeTrue();
+ gate.TrySetResult();
+
+ var thrown = await Should.ThrowAsync(() => inFlight);
+ thrown.InnerException.ShouldBeOfType();
+ }
+
+ [Fact]
+ public async Task A_fetch_that_completes_after_a_reinitialize_does_not_overwrite_the_newer_cache()
+ {
+ // The probe cache is re-published only onto the session it was fetched against. Without
+ // that check, a slow browse would install a device model belonging to a superseded
+ // configuration over the one the re-initialize just established — stale browse output that
+ // nothing would ever correct.
+ var built = new List();
+ var driver = new MTConnectDriver(Opts(), "mt1", _ =>
+ {
+ var c = CannedAgentClient.FromFixtures();
+ built.Add(c);
+
+ return c;
+ });
+
+ await driver.InitializeAsync("{}", CancellationToken.None);
+ await driver.FlushOptionalCachesAsync(CancellationToken.None);
+
+ var client = built[0];
+ var staleModel = client.Probe;
+
+ // Park a fetch on the OLD (about to be superseded) session; it captures staleModel now.
+ // Held locally — the gate is one-shot and clears the client's own reference when it parks.
+ var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ client.ProbeGate = gate;
+ var inFlight = driver.GetOrFetchProbeModelAsync(CancellationToken.None);
+
+ // A config-only re-initialize: same client (so nothing is disposed), brand-new session.
+ var freshModel = new MTConnectProbeModel(staleModel.Devices);
+ client.Probe = freshModel;
+ await driver.ReinitializeAsync(
+ $$"""{"agentUri":"{{AgentUri}}","tags":[{"fullName":"dev1_pos","driverDataType":"Float64"}]}""",
+ CancellationToken.None);
+
+ built.Count.ShouldBe(1);
+ driver.CachedProbeModel.ShouldBeSameAs(freshModel);
+
+ // Now let the stale fetch finish. It still returns what the Agent gave it...
+ gate.TrySetResult();
+ (await inFlight).ShouldBeSameAs(staleModel);
+
+ // ...but it must not have published that over the newer session's cache.
+ driver.CachedProbeModel.ShouldBeSameAs(freshModel);
+ }
+
+ [Fact]
+ public async Task Flush_after_shutdown_is_a_no_op_and_does_not_resurrect_the_session()
+ {
+ // Core polls the footprint and asks for a flush on its own schedule, so a flush arriving
+ // after the driver was torn down is ordinary, not exotic. It must observe the retired
+ // session and do nothing — publishing a "flushed" copy of it would reinstate a session
+ // holding an already-disposed client for every later caller.
+ //
+ // NOTE: this pins the retired-session guard, NOT the CompareExchange beneath it. Flush has
+ // no await point, so nothing can be interleaved between its read and its publish from a
+ // single-threaded test; the CAS is defence-in-depth against a genuinely concurrent
+ // lifecycle call and is deliberately claimed as such rather than as tested behaviour. The
+ // fetch-side CAS, which does have an await point, is covered by the test above.
+ var (driver, client) = await InitializedDriverAsync();
+
+ await driver.ShutdownAsync(CancellationToken.None);
+ await driver.FlushOptionalCachesAsync(CancellationToken.None);
+
+ driver.CachedProbeModel.ShouldBeNull();
+ driver.GetHealth().State.ShouldBe(DriverState.Unknown);
+ client.DisposeCount.ShouldBe(1);
+
+ await Should.ThrowAsync(
+ () => driver.GetOrFetchProbeModelAsync(CancellationToken.None));
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverOptionsTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverOptionsTests.cs
new file mode 100644
index 00000000..91d1c4ca
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverOptionsTests.cs
@@ -0,0 +1,121 @@
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
+
+///
+/// Task 2 coverage for / :
+/// defaults are sane (non-zero timers, probe on) and the required-field shape (AgentUri) binds.
+///
+[Trait("Category", "Unit")]
+public sealed class MTConnectDriverOptionsTests
+{
+ /// Default sample/heartbeat timers are non-zero and the probe is enabled by default.
+ [Fact]
+ public void Options_default_sample_and_heartbeat_are_sane()
+ {
+ var o = new MTConnectDriverOptions { AgentUri = "http://agent:5000" };
+ o.SampleIntervalMs.ShouldBeGreaterThan(0);
+ o.HeartbeatMs.ShouldBeGreaterThan(0);
+ o.Probe.Enabled.ShouldBeTrue();
+ }
+
+ /// AgentUri is the only required field; DeviceName scope defaults to unset (agent-wide).
+ [Fact]
+ public void Options_agent_uri_is_required_device_name_defaults_to_null()
+ {
+ var o = new MTConnectDriverOptions { AgentUri = "http://agent:5000" };
+ o.AgentUri.ShouldBe("http://agent:5000");
+ o.DeviceName.ShouldBeNull();
+ }
+
+ /// RequestTimeoutMs and SampleCount default to sane, non-zero, non-wedging values.
+ [Fact]
+ public void Options_request_timeout_and_sample_count_are_non_zero()
+ {
+ var o = new MTConnectDriverOptions { AgentUri = "http://agent:5000" };
+ o.RequestTimeoutMs.ShouldBeGreaterThan(0);
+ o.SampleCount.ShouldBeGreaterThan(0);
+ }
+
+ /// Probe sub-options mirror ModbusProbeOptions' shape: Enabled/Interval/Timeout, both non-zero.
+ [Fact]
+ public void Probe_defaults_have_non_zero_interval_and_timeout()
+ {
+ var probe = new MTConnectDriverOptions { AgentUri = "http://agent:5000" }.Probe;
+ probe.Interval.ShouldBeGreaterThan(TimeSpan.Zero);
+ probe.Timeout.ShouldBeGreaterThan(TimeSpan.Zero);
+ }
+
+ /// Reconnect sub-options default to a bounded, non-degenerate geometric backoff.
+ [Fact]
+ public void Reconnect_defaults_bound_backoff_growth()
+ {
+ var reconnect = new MTConnectDriverOptions { AgentUri = "http://agent:5000" }.Reconnect;
+ reconnect.MaxBackoffMs.ShouldBeGreaterThan(reconnect.MinBackoffMs);
+ reconnect.BackoffMultiplier.ShouldBeGreaterThan(1.0);
+ }
+
+ ///
+ /// defaults to an empty collection, never
+ /// null — a null collection here is an operator-authorable NullReferenceException at
+ /// deploy, since a driver instance authored with no tags binds this default.
+ ///
+ [Fact]
+ public void Options_tags_default_to_empty_and_are_never_null()
+ {
+ var o = new MTConnectDriverOptions { AgentUri = "http://agent:5000" };
+
+ o.Tags.ShouldNotBeNull();
+ o.Tags.ShouldBeEmpty();
+ }
+
+ ///
+ /// A populated options object carries the authored tag definitions through unchanged —
+ /// the observation index (Task 8) coerces each present observation to its tag's
+ /// DriverDataType, so the definitions must reach the driver from the factory config.
+ ///
+ [Fact]
+ public void Options_round_trip_the_authored_tag_definitions()
+ {
+ var pos = new MTConnectTagDefinition(
+ FullName: "dev1_pos",
+ DriverDataType: ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Float64,
+ MtCategory: "SAMPLE",
+ MtType: "POSITION",
+ Units: "MILLIMETER");
+ var partCount = new MTConnectTagDefinition(
+ FullName: "dev1_partcount",
+ DriverDataType: ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Int64,
+ MtCategory: "EVENT",
+ MtType: "PART_COUNT");
+
+ var o = new MTConnectDriverOptions { AgentUri = "http://agent:5000", Tags = [pos, partCount] };
+
+ o.Tags.Count.ShouldBe(2);
+ o.Tags[0].ShouldBe(pos);
+ o.Tags[1].ShouldBe(partCount);
+ }
+
+ /// MTConnectTagDefinition round-trips its positional fields, including MTConnect metadata.
+ [Fact]
+ public void TagDefinition_carries_dataItemId_and_mtconnect_metadata()
+ {
+ var tag = new MTConnectTagDefinition(
+ FullName: "dtop_2",
+ DriverDataType: ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Float64,
+ MtCategory: "SAMPLE",
+ MtType: "POSITION",
+ MtSubType: "ACTUAL",
+ Units: "MILLIMETER");
+
+ tag.FullName.ShouldBe("dtop_2");
+ tag.DriverDataType.ShouldBe(ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverDataType.Float64);
+ tag.IsArray.ShouldBeFalse();
+ tag.ArrayDim.ShouldBe(0);
+ tag.MtCategory.ShouldBe("SAMPLE");
+ tag.MtType.ShouldBe("POSITION");
+ tag.MtSubType.ShouldBe("ACTUAL");
+ tag.Units.ShouldBe("MILLIMETER");
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverProbeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverProbeTests.cs
new file mode 100644
index 00000000..03ce5781
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectDriverProbeTests.cs
@@ -0,0 +1,319 @@
+using System.Net;
+using System.Net.Http;
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
+
+///
+/// Task 14 — : the AdminUI Test Connect reachability probe.
+/// Response-shape cases (non-MTConnect body, MTConnectError-under-200, a valid device model) are
+/// exercised through a stubbed via the internal test-seam
+/// constructor, so no socket is needed; the unreachable-agent and non-positive-timeout cases use
+/// a real (deliberately unroutable/refused) TCP target, mirroring the plan's canned example.
+///
+[Trait("Category", "Unit")]
+public sealed class MTConnectDriverProbeTests
+{
+ private static readonly TimeSpan QuickTimeout = TimeSpan.FromSeconds(3);
+
+ // ── stub handler ─────────────────────────────────────────────────────────
+
+ /// Answers every request with a canned response (or throws) via a delegate, with no socket.
+ private sealed class StubHandler(Func> respond)
+ : HttpMessageHandler
+ {
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) =>
+ respond(request, ct);
+ }
+
+ private static HttpResponseMessage XmlResponse(string xml) => new(HttpStatusCode.OK)
+ {
+ Content = new StringContent(xml, System.Text.Encoding.UTF8, "application/xml"),
+ };
+
+ private static MTConnectDriverProbe ProbeWithHandler(
+ Func> respond) =>
+ new(new StubHandler(respond));
+
+ private const string ValidProbeXml =
+ """
+
+
+
+
+
+
+
+
+
+
+
+ """;
+
+ private const string MTConnectErrorXml =
+ """
+
+
+
+
+ Could not find the device named 'nope'.
+
+
+ """;
+
+ private const string NotMTConnectXml =
+ """
+
+
+ """;
+
+ // ── from the plan (verbatim) ─────────────────────────────────────────────
+
+ [Fact]
+ public async Task Probe_on_unreachable_agent_returns_not_ok_and_does_not_throw()
+ {
+ var probe = new MTConnectDriverProbe();
+ var r = await probe.ProbeAsync("{\"agentUri\":\"http://127.0.0.1:1/\"}", TimeSpan.FromMilliseconds(300), default);
+ r.Ok.ShouldBeFalse();
+ r.Message.ShouldNotBeNull();
+ }
+
+ [Fact]
+ public async Task Probe_on_blank_config_returns_not_ok()
+ => (await new MTConnectDriverProbe().ProbeAsync("{}", TimeSpan.FromSeconds(1), default)).Ok.ShouldBeFalse();
+
+ // ── unparseable JSON ──────────────────────────────────────────────────────
+
+ [Fact]
+ public async Task Unparseable_json_returns_not_ok_and_does_not_throw()
+ {
+ var probe = new MTConnectDriverProbe();
+
+ var r = await probe.ProbeAsync("not valid json {{", QuickTimeout, CancellationToken.None);
+
+ r.Ok.ShouldBeFalse();
+ r.Message.ShouldNotBeNull();
+ r.Message.ShouldContain("invalid", Case.Insensitive);
+ }
+
+ // ── malformed / relative URI ─────────────────────────────────────────────
+
+ [Theory]
+ [InlineData("not a uri")]
+ [InlineData("/relative/path")]
+ [InlineData("ftp://host/probe")]
+ public async Task Malformed_or_non_http_agentUri_returns_not_ok(string agentUri)
+ {
+ var probe = new MTConnectDriverProbe();
+
+ var r = await probe.ProbeAsync($"{{\"agentUri\":\"{agentUri}\"}}", QuickTimeout, CancellationToken.None);
+
+ r.Ok.ShouldBeFalse();
+ r.Message.ShouldNotBeNull();
+ }
+
+ // ── credential redaction ─────────────────────────────────────────────────
+
+ [Fact]
+ public async Task AgentUri_credentials_never_appear_in_the_message()
+ {
+ var probe = new MTConnectDriverProbe();
+
+ var r = await probe.ProbeAsync(
+ "{\"agentUri\":\"http://sneaky-user:sneaky-pass@127.0.0.1:1/\"}",
+ TimeSpan.FromMilliseconds(300),
+ CancellationToken.None);
+
+ r.Ok.ShouldBeFalse();
+ r.Message.ShouldNotBeNull();
+ r.Message.ShouldNotContain("sneaky-pass");
+ r.Message.ShouldNotContain("sneaky-user:sneaky-pass");
+ }
+
+ // ── 200 body that is not MTConnect at all ────────────────────────────────
+
+ [Fact]
+ public async Task NonMTConnect_200_body_returns_not_ok()
+ {
+ var probe = ProbeWithHandler((_, _) => Task.FromResult(XmlResponse(NotMTConnectXml)));
+
+ var r = await probe.ProbeAsync(
+ "{\"agentUri\":\"http://stub-agent/\"}", QuickTimeout, CancellationToken.None);
+
+ r.Ok.ShouldBeFalse();
+ r.Message.ShouldNotBeNull();
+ r.Latency.ShouldBeNull();
+ }
+
+ // ── MTConnectError under HTTP 200 ────────────────────────────────────────
+
+ [Fact]
+ public async Task MTConnectError_under_200_returns_not_ok_with_agents_own_message()
+ {
+ var probe = ProbeWithHandler((_, _) => Task.FromResult(XmlResponse(MTConnectErrorXml)));
+
+ var r = await probe.ProbeAsync(
+ "{\"agentUri\":\"http://stub-agent/\"}", QuickTimeout, CancellationToken.None);
+
+ r.Ok.ShouldBeFalse();
+ r.Message.ShouldNotBeNull();
+ r.Message.ShouldContain("NO_DEVICE");
+ r.Message.ShouldContain("Could not find the device named 'nope'.");
+ }
+
+ // ── valid MTConnectDevices body ───────────────────────────────────────────
+
+ [Fact]
+ public async Task Valid_probe_document_returns_ok_true_with_latency()
+ {
+ var probe = ProbeWithHandler((_, _) => Task.FromResult(XmlResponse(ValidProbeXml)));
+
+ var r = await probe.ProbeAsync(
+ "{\"agentUri\":\"http://stub-agent/\"}", QuickTimeout, CancellationToken.None);
+
+ r.Ok.ShouldBeTrue();
+ r.Message.ShouldNotBeNull();
+ r.Latency.ShouldNotBeNull();
+ r.Latency!.Value.ShouldBeGreaterThanOrEqualTo(TimeSpan.Zero);
+ r.Latency!.Value.ShouldBeLessThan(QuickTimeout);
+ }
+
+ // ── timeout bounded (hard hang-risk guard) ───────────────────────────────
+
+ [Fact(Timeout = 5000)]
+ public async Task Frozen_peer_times_out_within_the_requested_deadline_not_hang()
+ {
+ var probe = ProbeWithHandler(async (_, ct) =>
+ {
+ await Task.Delay(Timeout.Infinite, ct); // never answers until cancelled
+ throw new UnreachableException();
+ });
+
+ var sw = System.Diagnostics.Stopwatch.StartNew();
+ var r = await probe.ProbeAsync(
+ "{\"agentUri\":\"http://stub-agent/\"}", TimeSpan.FromMilliseconds(300), CancellationToken.None);
+ sw.Stop();
+
+ r.Ok.ShouldBeFalse();
+ r.Message.ShouldNotBeNull();
+ r.Message.ShouldContain("timed out", Case.Insensitive);
+ sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(4));
+ }
+
+ // ── non-positive timeout does not mean "wait forever" ────────────────────
+
+ [Fact(Timeout = 8000)]
+ public async Task Non_positive_timeout_falls_back_to_a_bounded_default_not_infinite()
+ {
+ var probe = ProbeWithHandler(async (_, ct) =>
+ {
+ await Task.Delay(Timeout.Infinite, ct); // never answers until cancelled
+ throw new UnreachableException();
+ });
+
+ var r = await probe.ProbeAsync(
+ "{\"agentUri\":\"http://stub-agent/\"}", TimeSpan.Zero, CancellationToken.None);
+
+ r.Ok.ShouldBeFalse();
+ r.Message.ShouldNotBeNull();
+ }
+
+ [Fact]
+ public async Task Negative_timeout_on_unreachable_agent_still_returns_promptly()
+ {
+ var probe = new MTConnectDriverProbe();
+
+ var r = await probe.ProbeAsync(
+ "{\"agentUri\":\"http://127.0.0.1:1/\"}", TimeSpan.FromSeconds(-1), CancellationToken.None);
+
+ r.Ok.ShouldBeFalse();
+ }
+
+ /// Local stand-in so the stub handler's "never returns" branch has a throw statement to satisfy flow analysis.
+ private sealed class UnreachableException : Exception;
+
+ // ── review follow-up: pathological (out-of-CancelAfter-range) timeout ────
+
+ ///
+ /// Reproduces the reviewer's finding: CancelAfter's legal range tops out near ~49.7
+ /// days, and the caller-supplied timeout reached it uncapped (only floored on the low
+ /// end). A caller with no clamp of its own — unlike today's only caller,
+ /// AdminOperationsActor, which clamps to [1,60]s — must still get a
+ /// , never an escaping
+ /// : that is the whole point of the "Never throws"
+ /// contract.
+ ///
+ [Fact]
+ public async Task Pathologically_large_timeout_does_not_throw()
+ {
+ var probe = new MTConnectDriverProbe();
+
+ var r = await probe.ProbeAsync(
+ "{\"agentUri\":\"http://127.0.0.1:1/\"}", TimeSpan.FromDays(1000), CancellationToken.None);
+
+ r.Ok.ShouldBeFalse();
+ r.Message.ShouldNotBeNull();
+ }
+
+ // ── review follow-up: unenumerated exception type must not leak ex.Message ──
+
+ /// Message text crafted to look like it embedded a credentialed URI, exactly what an unaudited ex.Message could leak.
+ private sealed class LeakyException()
+ : Exception("dial failed for http://leaky-user:leaky-pass@internal-host/probe (boom)");
+
+ ///
+ /// Reproduces the reviewer's finding: the final catch (Exception ex) forwarded
+ /// ex.Message verbatim for any type not enumerated above it, breaking the redaction
+ /// discipline every other catch was audited for. No currently-reachable exception type
+ /// exploits this, but nothing stopped a future one from doing so either — this test closes
+ /// that open door by asserting the final catch never echoes exception text.
+ ///
+ [Fact]
+ public async Task Unenumerated_exception_type_does_not_leak_ex_Message_via_final_catch()
+ {
+ var probe = ProbeWithHandler((_, _) => Task.FromException(new LeakyException()));
+
+ var r = await probe.ProbeAsync(
+ "{\"agentUri\":\"http://stub-agent/\"}", QuickTimeout, CancellationToken.None);
+
+ r.Ok.ShouldBeFalse();
+ r.Message.ShouldNotBeNull();
+ r.Message.ShouldNotContain("leaky-pass");
+ r.Message.ShouldNotContain("leaky-user:leaky-pass");
+ }
+
+ // ── minor: genuine non-2xx status (not just connection-refused) ──────────
+
+ [Fact]
+ public async Task NonSuccess_status_code_returns_not_ok()
+ {
+ var probe = ProbeWithHandler((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)
+ {
+ Content = new StringContent(string.Empty),
+ }));
+
+ var r = await probe.ProbeAsync(
+ "{\"agentUri\":\"http://stub-agent/\"}", QuickTimeout, CancellationToken.None);
+
+ r.Ok.ShouldBeFalse();
+ r.Message.ShouldNotBeNull();
+ r.Latency.ShouldBeNull();
+ }
+
+ // ── minor: credentialed agentUri + a SUCCESSFUL response must still redact ──
+
+ [Fact]
+ public async Task Credentialed_agentUri_is_redacted_even_on_a_successful_probe()
+ {
+ var probe = ProbeWithHandler((_, _) => Task.FromResult(XmlResponse(ValidProbeXml)));
+
+ var r = await probe.ProbeAsync(
+ "{\"agentUri\":\"http://stub-user:stub-pass@stub-agent/\"}", QuickTimeout, CancellationToken.None);
+
+ r.Ok.ShouldBeTrue();
+ r.Message.ShouldNotBeNull();
+ r.Message.ShouldNotContain("stub-pass");
+ r.Message.ShouldNotContain("stub-user:stub-pass");
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectFactoryTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectFactoryTests.cs
new file mode 100644
index 00000000..49dea03c
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectFactoryTests.cs
@@ -0,0 +1,352 @@
+using System.Diagnostics;
+using System.Net;
+using System.Net.Sockets;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Core.Hosting;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
+
+///
+/// Task 15 — : the seam that turns a
+/// DriverInstance row's DriverConfig JSON into a live .
+///
+/// Three properties carry the weight here. (1) One parser. The factory delegates to
+/// MTConnectDriver.ParseOptions rather than deserializing a second DTO, so the config
+/// document has a single authority and the runtime's ReinitializeAsync path cannot
+/// drift from the bootstrap path. (2) Connection-free construction. The Wave-0
+/// universal browser builds a throwaway instance purely to read
+/// SupportsOnlineDiscovery, so a factory that dialled would open a socket per AdminUI
+/// browse render against a possibly-unreachable Agent. (3) No dropped capability. The
+/// registry hands the runtime an ; every capability is resolved by
+/// pattern-matching the concrete instance, so the interface set on the object the factory
+/// returns IS the dispatch surface.
+///
+///
+public sealed class MTConnectFactoryTests
+{
+ private const uint Good = 0x00000000u;
+ private const uint BadWaitingForInitialData = 0x80320000u;
+ private const uint BadNodeIdUnknown = 0x80340000u;
+
+ // ----------------------------------------------------------------- construction
+
+ /// The plan's headline case: a one-key document is a complete MTConnect config.
+ [Fact]
+ public void Factory_builds_a_driver_from_minimal_config()
+ {
+ var d = MTConnectDriverFactoryExtensions.CreateInstance("mt1", "{\"agentUri\":\"http://a:5000\"}");
+
+ d.DriverType.ShouldBe("MTConnect");
+ d.DriverInstanceId.ShouldBe("mt1");
+ }
+
+ ///
+ /// The factory's own type name and the instance's must be
+ /// the same string, or a registered factory would materialise a driver the runtime looks up
+ /// under a different key.
+ ///
+ [Fact]
+ public void DriverTypeName_matches_the_instances_DriverType()
+ {
+ var d = MTConnectDriverFactoryExtensions.CreateInstance("mt1", "{\"agentUri\":\"http://a:5000\"}");
+
+ MTConnectDriverFactoryExtensions.DriverTypeName.ShouldBe("MTConnect");
+ d.DriverType.ShouldBe(MTConnectDriverFactoryExtensions.DriverTypeName);
+ }
+
+ // ----------------------------------------------------------------- rejection
+
+ ///
+ /// No agentUri means there is no Agent to talk to. Throwing is correct: the browser's
+ /// CanBrowse catches factory exceptions (a half-authored config just disables the
+ /// picker), and a deployment carrying such a row must fail rather than register a driver
+ /// pointed at nothing.
+ ///
+ [Fact]
+ public void Factory_rejects_config_without_agentUri()
+ => Should.Throw(() => MTConnectDriverFactoryExtensions.CreateInstance("mt1", "{}"));
+
+ ///
+ /// A whitespace-only agentUri is the same absence spelled differently — it must not
+ /// slip past the required check and become a driver dialling an empty URI.
+ ///
+ [Fact]
+ public void Factory_rejects_a_blank_agentUri()
+ => Should.Throw(
+ () => MTConnectDriverFactoryExtensions.CreateInstance("mt1", "{\"agentUri\":\" \"}"));
+
+ ///
+ /// Unparseable JSON faults loudly rather than silently yielding a defaulted driver — the
+ /// "empty bytes are not an empty configuration" rule (#485) applied at the factory seam.
+ ///
+ [Fact]
+ public void Factory_rejects_unparseable_json_rather_than_defaulting()
+ {
+ var ex = Should.Throw(
+ () => MTConnectDriverFactoryExtensions.CreateInstance("mt1", "{ this is not json"));
+
+ ex.Message.ShouldContain("mt1");
+ }
+
+ /// A JSON null document is not a config either.
+ [Fact]
+ public void Factory_rejects_a_null_json_document()
+ => Should.Throw(() => MTConnectDriverFactoryExtensions.CreateInstance("mt1", "null"));
+
+ /// An empty config string has nothing to build from; the id likewise.
+ [Theory]
+ [InlineData("mt1", "")]
+ [InlineData("mt1", " ")]
+ [InlineData("", "{\"agentUri\":\"http://a:5000\"}")]
+ public void Factory_rejects_empty_arguments(string id, string json)
+ => Should.Throw(() => MTConnectDriverFactoryExtensions.CreateInstance(id, json));
+
+ // ----------------------------------------------------------------- connection-free
+
+ ///
+ /// The factory opens no socket. Asserted directly: a real listener is bound, the
+ /// driver is built against its address, and nothing ever arrives on the accept queue. A
+ /// regression here would make every AdminUI browse render dial the Agent.
+ ///
+ [Fact]
+ public async Task CreateInstance_opens_no_socket_to_the_configured_agent()
+ {
+ var listener = new TcpListener(IPAddress.Loopback, 0);
+ listener.Start();
+ try
+ {
+ var port = ((IPEndPoint)listener.LocalEndpoint).Port;
+
+ var sw = Stopwatch.StartNew();
+ var driver = MTConnectDriverFactoryExtensions.CreateInstance(
+ "mt1", $"{{\"agentUri\":\"http://127.0.0.1:{port}\"}}");
+ sw.Stop();
+
+ driver.ShouldNotBeNull();
+
+ // Generous grace for a connect that a background thread might land late.
+ await Task.Delay(250, TestContext.Current.CancellationToken);
+
+ listener.Pending().ShouldBeFalse(
+ "MTConnectDriverFactoryExtensions.CreateInstance dialled the Agent — the browser " +
+ "builds a throwaway instance per browse probe, so construction must open nothing.");
+ sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(2));
+ }
+ finally
+ {
+ listener.Stop();
+ listener.Dispose();
+ }
+ }
+
+ ///
+ /// The same property against an address that can never answer (TEST-NET-1, RFC 5737):
+ /// construction returns promptly instead of blocking on a connect that will only end in a
+ /// timeout.
+ ///
+ [Fact]
+ public void CreateInstance_returns_immediately_for_a_black_hole_agent()
+ {
+ var sw = Stopwatch.StartNew();
+ var driver = MTConnectDriverFactoryExtensions.CreateInstance(
+ "mt1", "{\"agentUri\":\"http://192.0.2.1:5000\"}");
+ sw.Stop();
+
+ driver.DriverInstanceId.ShouldBe("mt1");
+ sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(2));
+ }
+
+ // ----------------------------------------------------------------- registry wiring
+
+ ///
+ /// puts the type in the registry
+ /// under the same key the bootstrapper looks up, and the registered delegate — not just the
+ /// static helper — builds a working instance.
+ ///
+ [Fact]
+ public void Register_adds_the_type_and_the_registered_delegate_builds_a_driver()
+ {
+ var registry = new DriverFactoryRegistry();
+
+ MTConnectDriverFactoryExtensions.Register(registry);
+
+ registry.RegisteredTypes.ShouldContain("MTConnect");
+
+ var factory = registry.TryGet("MTConnect");
+ factory.ShouldNotBeNull();
+
+ var driver = factory("mt-from-registry", "{\"agentUri\":\"http://a:5000\"}");
+
+ driver.DriverInstanceId.ShouldBe("mt-from-registry");
+ driver.DriverType.ShouldBe("MTConnect");
+ }
+
+ ///
+ /// The anti-dormancy pin. The runtime resolves every optional capability by
+ /// pattern-matching the instance the registry hands back, so a capability the driver
+ /// implements but the factory's return path hides would be implemented-yet-never-dispatched
+ /// — a failure shape this repo has shipped twice. Asserted on the
+ /// the registry delegate returns, which is exactly what the bootstrapper holds.
+ ///
+ [Fact]
+ public void Registered_delegate_returns_an_instance_carrying_every_capability()
+ {
+ var registry = new DriverFactoryRegistry();
+ MTConnectDriverFactoryExtensions.Register(registry);
+
+ IDriver driver = registry.TryGet("MTConnect")!("mt1", "{\"agentUri\":\"http://a:5000\"}");
+
+ driver.ShouldBeOfType();
+ (driver is IReadable).ShouldBeTrue("MTConnect must dispatch reads");
+ (driver is ISubscribable).ShouldBeTrue("MTConnect must dispatch subscriptions");
+ (driver is ITagDiscovery).ShouldBeTrue("MTConnect must dispatch browse/discovery");
+ (driver is IHostConnectivityProbe).ShouldBeTrue("MTConnect must dispatch host connectivity");
+ (driver is IRediscoverable).ShouldBeTrue("MTConnect must dispatch rediscovery");
+ }
+
+ ///
+ /// The other half of the capability pin: MTConnect's Agent surface is read-only, so the
+ /// instance must NOT be . If it ever became writable, the write gate
+ /// would start offering an operation the protocol cannot honour.
+ ///
+ [Fact]
+ public void Registered_delegate_returns_an_instance_that_is_not_writable()
+ {
+ var registry = new DriverFactoryRegistry();
+ MTConnectDriverFactoryExtensions.Register(registry);
+
+ IDriver driver = registry.TryGet("MTConnect")!("mt1", "{\"agentUri\":\"http://a:5000\"}");
+
+ (driver is IWritable).ShouldBeFalse("the MTConnect Agent surface is read-only");
+ }
+
+ /// Registering into a null registry is a wiring bug, not a silent no-op.
+ [Fact]
+ public void Register_rejects_a_null_registry()
+ => Should.Throw(() => MTConnectDriverFactoryExtensions.Register(null!));
+
+ // ----------------------------------------------------------------- config round-trip
+
+ ///
+ /// An authored tags array reaches options.Tags. Observed through the
+ /// observation index the constructor builds from those options: an authored id answers
+ /// BadWaitingForInitialData ("subscribed, no value yet"), an unauthored one answers
+ /// BadNodeIdUnknown. Status codes are literals so a wrong constant cannot be masked
+ /// by both sides sharing one symbol.
+ ///
+ [Fact]
+ public void Authored_tags_round_trip_into_the_driver_options()
+ {
+ const string json = """
+ {
+ "agentUri": "http://a:5000",
+ "tags": [
+ { "fullName": "dev1_pos", "driverDataType": "Float64" },
+ { "fullName": "dev1_execution", "driverDataType": "String" }
+ ]
+ }
+ """;
+
+ var driver = MTConnectDriverFactoryExtensions.CreateInstance("mt1", json);
+
+ driver.ObservationIndex.Get("dev1_pos").StatusCode.ShouldBe(BadWaitingForInitialData);
+ driver.ObservationIndex.Get("dev1_execution").StatusCode.ShouldBe(BadWaitingForInitialData);
+ driver.ObservationIndex.Get("dev1_never_authored").StatusCode.ShouldBe(BadNodeIdUnknown);
+ }
+
+ ///
+ /// An enum authored as a name parses to that member — proved by behaviour, not by
+ /// reading the option back: a Float64 tag coerces the Agent's text into a real
+ /// , whereas the enum's member 0 (Boolean) or a defaulted
+ /// String would not.
+ ///
+ [Fact]
+ public void Enum_fields_authored_as_names_parse_to_that_member()
+ {
+ var driver = MTConnectDriverFactoryExtensions.CreateInstance(
+ "mt1",
+ """
+ { "agentUri": "http://a:5000",
+ "tags": [ { "fullName": "dev1_pos", "driverDataType": "Float64" } ] }
+ """);
+
+ driver.ObservationIndex.Apply(new MTConnectStreamsResult(
+ InstanceId: 1L,
+ NextSequence: 2L,
+ FirstSequence: 1L,
+ Observations: [new MTConnectObservation(
+ "dev1_pos", "12.5", new DateTime(2026, 7, 24, 12, 0, 0, DateTimeKind.Utc), false)]));
+
+ var snap = driver.ObservationIndex.Get("dev1_pos");
+
+ snap.StatusCode.ShouldBe(Good);
+ snap.Value.ShouldBeOfType().ShouldBe(12.5d);
+ }
+
+ ///
+ /// The enum-serialization trap. The factory deliberately carries no
+ /// JsonStringEnumConverter: a numerically-serialized enum — the shape an AdminUI page
+ /// that serialized the enum by value would emit — must fault at deploy rather than silently
+ /// landing on member 0 (Boolean), which would mis-coerce every value of that tag.
+ ///
+ [Fact]
+ public void Numerically_serialized_enum_faults_rather_than_landing_on_member_zero()
+ => Should.Throw(() => MTConnectDriverFactoryExtensions.CreateInstance(
+ "mt1",
+ """
+ { "agentUri": "http://a:5000",
+ "tags": [ { "fullName": "dev1_pos", "driverDataType": 8 } ] }
+ """));
+
+ /// An enum name that names no member is an authoring error, reported with the field.
+ [Fact]
+ public void Unknown_enum_name_faults_naming_the_offending_tag()
+ {
+ var ex = Should.Throw(() => MTConnectDriverFactoryExtensions.CreateInstance(
+ "mt1",
+ """
+ { "agentUri": "http://a:5000",
+ "tags": [ { "fullName": "dev1_pos", "driverDataType": "Flot64" } ] }
+ """));
+
+ ex.Message.ShouldContain("dev1_pos");
+ }
+
+ ///
+ /// Scalar knobs reach the options too — the factory must not quietly drop a section it does
+ /// not itself read. Proved through the one seam a non-initialized driver exposes: an
+ /// authored reconnect block changes the backoff the driver would apply.
+ ///
+ [Fact]
+ public void Reconnect_and_probe_sections_round_trip_into_the_driver_options()
+ {
+ const string json = """
+ {
+ "agentUri": "http://a:5000",
+ "deviceName": "dev1",
+ "requestTimeoutMs": 1234,
+ "reconnect": { "minBackoffMs": 500, "maxBackoffMs": 4000, "backoffMultiplier": 3.0 },
+ "probe": { "enabled": false, "intervalMs": 7000, "timeoutMs": 900 }
+ }
+ """;
+
+ var options = MTConnectDriver.ParseOptions("mt1", json);
+
+ options.AgentUri.ShouldBe("http://a:5000");
+ options.DeviceName.ShouldBe("dev1");
+ options.RequestTimeoutMs.ShouldBe(1234);
+ options.Reconnect.MinBackoffMs.ShouldBe(500);
+ options.Reconnect.MaxBackoffMs.ShouldBe(4000);
+ options.Reconnect.BackoffMultiplier.ShouldBe(3.0);
+ options.Probe.Enabled.ShouldBeFalse();
+ options.Probe.Interval.ShouldBe(TimeSpan.FromMilliseconds(7000));
+ options.Probe.Timeout.ShouldBe(TimeSpan.FromMilliseconds(900));
+
+ // …and the factory is built on exactly that parse, not a second DTO: the same document
+ // through the factory must produce an instance, and the same rejection rules (asserted
+ // above) hold for both entry points.
+ MTConnectDriverFactoryExtensions.CreateInstance("mt1", json).DriverInstanceId.ShouldBe("mt1");
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectHostAndRediscoverTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectHostAndRediscoverTests.cs
new file mode 100644
index 00000000..0b9e3e1b
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectHostAndRediscoverTests.cs
@@ -0,0 +1,676 @@
+using System.Collections.Concurrent;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
+
+///
+/// Task 13 — 's two optional capabilities:
+/// (a periodic reachability tick that flips one host
+/// Running ↔ Stopped) and (the Agent instanceId watch that
+/// is the ONLY thing making safe).
+///
+///
+///
+/// Nothing here waits on wall-clock time, and the probe is a timer. The driver takes
+/// its inter-tick delay through an injected scheduler seam, and this suite supplies
+/// : the loop parks in the seam, the test releases exactly one
+/// tick, and the ticker's next park is the proof that the probe which followed has already
+/// completed. No test sleeps, no test polls a counter, and
+/// stays timer-free.
+///
+///
+/// The load-bearing tests are the anti-inertness ones. Both capabilities have a
+/// plausible-looking implementation that compiles, ships, and does nothing:
+/// a connectivity probe routed through the driver's cached /probe accessor never
+/// touches the network once the cache is warm (so it reports Running forever, whatever the
+/// Agent is doing), and a rediscovery signal raised without dropping that same cache makes
+/// Core re-run discovery and receive the identical stale device tree. Both are pinned here
+/// by asserting the Agent was actually called.
+///
+///
+public sealed class MTConnectHostAndRediscoverTests
+{
+ private const string AgentUri = "http://fixture-agent:5000";
+
+ /// The instanceId every canned fixture shares.
+ private const long FixtureInstanceId = 1655000000L;
+
+ /// A DIFFERENT instanceId — the Agent came back as a new process.
+ private const long RestartedInstanceId = 1655999999L;
+
+ /// A third instanceId — the Agent restarted twice.
+ private const long SecondRestartInstanceId = 1656111111L;
+
+ /// The cursor Fixtures/current.xml primes the pump with.
+ private const long PrimedNextSequence = 108L;
+
+ ///
+ /// Failure guard for the awaits a defect could turn into a permanent hang (a probe loop that
+ /// never parks, a teardown that deadlocks on the lifecycle semaphore). It exists to make a
+ /// hang red, not to assert a duration — no assertion is ever made about elapsed time.
+ ///
+ private static readonly TimeSpan Watchdog = TimeSpan.FromSeconds(10);
+
+ private static readonly DateTime ObservedAt = new(2026, 7, 24, 12, 30, 0, DateTimeKind.Utc);
+
+ /// The interval the probe tests author, asserted to reach the scheduler verbatim.
+ private static readonly TimeSpan AuthoredInterval = TimeSpan.FromSeconds(7);
+
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ // ---- fixtures ----
+
+ ///
+ /// Options with the background probe disabled — the default for the rediscovery half
+ /// of this suite, so no probe loop can add calls behind a ProbeCallCount assertion.
+ ///
+ private static MTConnectDriverOptions Opts(MTConnectProbeOptions? probe = null) =>
+ new()
+ {
+ AgentUri = AgentUri,
+ RequestTimeoutMs = 5000,
+ Probe = probe ?? new MTConnectProbeOptions { Enabled = false },
+ Tags =
+ [
+ new MTConnectTagDefinition("dev1_pos", DriverDataType.Float64),
+ new MTConnectTagDefinition("dev1_execution", DriverDataType.String),
+ ],
+ };
+
+ private static MTConnectProbeOptions Probing(bool enabled = true) =>
+ new() { Enabled = enabled, Interval = AuthoredInterval, Timeout = TimeSpan.FromSeconds(2) };
+
+ private static async Task<(MTConnectDriver Driver, CannedAgentClient Client)> InitializedDriverAsync(
+ CannedAgentClient? client = null,
+ MTConnectDriverOptions? options = null,
+ ManualTicker? ticker = null)
+ {
+ var agent = client ?? CannedAgentClient.FromFixtures();
+ var driver = new MTConnectDriver(
+ options ?? Opts(), "mt1", _ => agent, probeScheduler: ticker is null ? null : ticker.WaitAsync);
+
+ await driver.InitializeAsync("{}", Ct);
+
+ return (driver, agent);
+ }
+
+ ///
+ /// Brings a driver up with the probe loop wired to a manual ticker, and returns once the loop
+ /// is demonstrably parked in its first inter-tick wait (i.e. it has run zero probes).
+ ///
+ private static async Task<(MTConnectDriver Driver, CannedAgentClient Client, ManualTicker Ticker)>
+ ProbingDriverAsync(CannedAgentClient? client = null)
+ {
+ var ticker = new ManualTicker();
+ var (driver, agent) = await InitializedDriverAsync(client, Opts(Probing()), ticker);
+
+ await ticker.ParkedAsync().WaitAsync(Watchdog, Ct);
+
+ return (driver, agent, ticker);
+ }
+
+ ///
+ /// Releases exactly one probe tick and returns once the probe it triggered has completed —
+ /// proven by the loop parking again, not by a delay.
+ ///
+ private static async Task TickAsync(ManualTicker ticker)
+ {
+ ticker.Tick();
+ await ticker.ParkedAsync().WaitAsync(Watchdog, Ct);
+ }
+
+ ///
+ /// Teardown symmetry with the /sample pump (remediation I1). The probe loop is
+ /// the second long-lived background task in this driver, and it is stopped the same way and
+ /// from the same place: while the lifecycle semaphore is held, on a path
+ /// DriverInstanceActor.PostStop blocks an Akka dispatcher thread on. So its wait is
+ /// bounded and honours the caller's token too — a blocking
+ /// handler must not be able to wedge
+ /// shutdown, any more than a blocking data-change handler can.
+ ///
+ ///
+ /// Its blast radius is genuinely smaller than the pump's (each iteration is bounded by the
+ /// validated-positive Probe.Timeout, where the pump's is an unbounded long poll), but
+ /// leaving the two divergent would make the un-bounded one the pattern a future reader
+ /// copies — the defect class would be closed in one place and re-opened in the other, in the
+ /// same file.
+ ///
+ [Fact]
+ public async Task Shutdown_honours_the_callers_deadline_when_a_host_status_subscriber_blocks()
+ {
+ var (driver, _, ticker) = await ProbingDriverAsync();
+ var blocking = new ManualResetEventSlim(false);
+ var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ ((IHostConnectivityProbe)driver).OnHostStatusChanged += (_, _) =>
+ {
+ entered.TrySetResult();
+ blocking.Wait();
+ };
+
+ try
+ {
+ // The first successful probe transitions Unknown -> Running and raises the event, which
+ // now blocks inside the loop.
+ ticker.Tick();
+ await entered.Task.WaitAsync(Watchdog, Ct);
+
+ using var deadline = new CancellationTokenSource(TimeSpan.FromMilliseconds(100));
+ await driver.ShutdownAsync(deadline.Token).WaitAsync(Watchdog, Ct);
+
+ driver.ProbeLoopTask.ShouldBeNull();
+ }
+ finally
+ {
+ blocking.Set();
+ }
+ }
+
+ private static ConcurrentQueue RecordRediscovery(MTConnectDriver driver)
+ {
+ var seen = new ConcurrentQueue();
+ ((IRediscoverable)driver).OnRediscoveryNeeded += (_, e) => seen.Enqueue(e);
+
+ return seen;
+ }
+
+ private static ConcurrentQueue RecordHostStatus(MTConnectDriver driver)
+ {
+ var seen = new ConcurrentQueue();
+ ((IHostConnectivityProbe)driver).OnHostStatusChanged += (_, e) => seen.Enqueue(e);
+
+ return seen;
+ }
+
+ private static MTConnectStreamsResult ChunkFrom(
+ long instanceId, long firstSequence, long nextSequence, params (string Id, string Value)[] observations) =>
+ new(
+ instanceId,
+ nextSequence,
+ firstSequence,
+ [.. observations.Select(o => new MTConnectObservation(o.Id, o.Value, ObservedAt))]);
+
+ /// A device model that is recognisably NOT the canned fixture's.
+ private static MTConnectProbeModel OtherModel() =>
+ new([
+ new MTConnectDevice(
+ "other-device",
+ "other-device",
+ [],
+ [new MTConnectDataItem("other_item", "other_item", "EVENT", "AVAILABILITY", null, null, null, null)]),
+ ]);
+
+ // ---- IRediscoverable: the instanceId watch ----
+
+ ///
+ /// The plan's TDD case. is
+ /// , so the runtime runs exactly one discovery
+ /// pass per connect — an Agent that restarts and renumbers (or adds) data items would leave a
+ /// stale address space behind a Healthy driver. This event is the only thing that makes that
+ /// policy safe.
+ ///
+ [Fact]
+ public async Task InstanceId_change_raises_rediscovery()
+ {
+ var (driver, client) = await InitializedDriverAsync();
+ var seen = RecordRediscovery(driver);
+ await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct);
+
+ client.CurrentAnswers.Enqueue(ChunkFrom(RestartedInstanceId, 40, 42, ("dev1_pos", "9.5")));
+
+ await client
+ .PumpAsync(ChunkFrom(RestartedInstanceId, 5000, 5005, ("dev1_pos", "5.5")))
+ .WaitAsync(Watchdog, Ct);
+
+ var raised = seen.ShouldHaveSingleItem();
+ raised.Reason.ShouldBe("MTConnect agent instanceId changed");
+ raised.ScopeHint.ShouldBeNull();
+ }
+
+ ///
+ /// Once per change, not once per chunk. The chunks that follow a restart all carry the
+ /// new instanceId; a driver that compared each chunk against the id it was
+ /// initialized with — or that simply re-raised on every chunk — would ask Core to
+ /// rebuild the whole address space on every heartbeat, forever.
+ ///
+ [Fact]
+ public async Task InstanceId_change_raises_rediscovery_once_per_change_not_once_per_chunk()
+ {
+ var (driver, client) = await InitializedDriverAsync();
+ var seen = RecordRediscovery(driver);
+ await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct);
+
+ client.CurrentAnswers.Enqueue(ChunkFrom(RestartedInstanceId, 40, 42, ("dev1_pos", "9.5")));
+
+ await client
+ .PumpAsync(ChunkFrom(RestartedInstanceId, 5000, 5005, ("dev1_pos", "5.5")))
+ .WaitAsync(Watchdog, Ct);
+ seen.Count.ShouldBe(1);
+
+ // Two more chunks from the SAME restarted Agent, contiguous with the re-baseline.
+ await client.PumpAsync(ChunkFrom(RestartedInstanceId, 42, 45, ("dev1_pos", "10.5"))).WaitAsync(Watchdog, Ct);
+ await client.PumpAsync(ChunkFrom(RestartedInstanceId, 45, 48, ("dev1_pos", "11.5"))).WaitAsync(Watchdog, Ct);
+
+ seen.Count.ShouldBe(1);
+
+ // …but a SECOND restart is a second change, and must be announced.
+ client.CurrentAnswers.Enqueue(ChunkFrom(SecondRestartInstanceId, 10, 12, ("dev1_pos", "1.5")));
+ await client
+ .PumpAsync(ChunkFrom(SecondRestartInstanceId, 6000, 6005, ("dev1_pos", "2.5")))
+ .WaitAsync(Watchdog, Ct);
+
+ seen.Count.ShouldBe(2);
+ }
+
+ ///
+ /// An unchanged instanceId is the overwhelmingly common case — every ordinary chunk,
+ /// every heartbeat, and every ring-buffer re-baseline of a still-running Agent. None of them
+ /// may cost an address-space rebuild.
+ ///
+ [Fact]
+ public async Task Unchanged_instanceId_raises_no_rediscovery()
+ {
+ var (driver, client) = await InitializedDriverAsync();
+ var seen = RecordRediscovery(driver);
+ await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct);
+
+ await client
+ .PumpAsync(ChunkFrom(FixtureInstanceId, PrimedNextSequence, 113, ("dev1_pos", "1.5")))
+ .WaitAsync(Watchdog, Ct);
+
+ // A heartbeat…
+ await client.PumpAsync(ChunkFrom(FixtureInstanceId, 1, 120)).WaitAsync(Watchdog, Ct);
+
+ // …and a ring-buffer gap, which re-baselines but is NOT a restart.
+ client.CurrentAnswers.Enqueue(ChunkFrom(FixtureInstanceId, 5000, 5005, ("dev1_pos", "2.5")));
+ await client
+ .PumpAsync(ChunkFrom(FixtureInstanceId, 5000, 5005, ("dev1_pos", "2.5")))
+ .WaitAsync(Watchdog, Ct);
+
+ seen.ShouldBeEmpty();
+ }
+
+ ///
+ /// The anti-inertness pin for . A restart invalidates the
+ /// cached /probe device model — it describes a process that no longer exists. Raising
+ /// the event while keeping that cache would have Core dutifully re-run
+ /// and receive the identical stale tree
+ /// from GetOrFetchProbeModelAsync's warm cache: a feature that fires, logs, and
+ /// changes nothing.
+ ///
+ [Fact]
+ public async Task InstanceId_change_drops_the_cached_probe_model_so_rediscovery_sees_the_new_one()
+ {
+ var (driver, client) = await InitializedDriverAsync();
+ await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct);
+ driver.CachedProbeModel.ShouldNotBeNull();
+
+ // The restarted Agent declares a different device model.
+ client.Probe = OtherModel();
+ client.CurrentAnswers.Enqueue(ChunkFrom(RestartedInstanceId, 40, 42, ("dev1_pos", "9.5")));
+
+ await client
+ .PumpAsync(ChunkFrom(RestartedInstanceId, 5000, 5005, ("dev1_pos", "5.5")))
+ .WaitAsync(Watchdog, Ct);
+
+ driver.CachedProbeModel.ShouldBeNull();
+
+ var probeCalls = client.ProbeCallCount;
+ var builder = new CapturingBuilder();
+ await driver.DiscoverAsync(builder, Ct);
+
+ // Discovery went back to the Agent, and streamed the NEW model rather than the old one.
+ client.ProbeCallCount.ShouldBe(probeCalls + 1);
+ builder.Variables.Select(v => v.Attr.FullName).ShouldBe(["other_item"]);
+ }
+
+ ///
+ /// A rediscovery handler is arbitrary caller code. One that throws is a bug in the consumer,
+ /// not a reason to stop streaming data to every subscriber on this driver.
+ ///
+ [Fact]
+ public async Task A_throwing_rediscovery_handler_does_not_kill_the_pump()
+ {
+ var (driver, client) = await InitializedDriverAsync();
+ var seen = new ConcurrentQueue();
+ driver.OnDataChange += (_, e) => seen.Enqueue(e);
+ ((IRediscoverable)driver).OnRediscoveryNeeded += (_, _) =>
+ throw new InvalidOperationException("rediscovery subscriber blew up");
+
+ await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct);
+ client.CurrentAnswers.Enqueue(ChunkFrom(RestartedInstanceId, 40, 42, ("dev1_pos", "9.5")));
+
+ await client
+ .PumpAsync(ChunkFrom(RestartedInstanceId, 5000, 5005, ("dev1_pos", "5.5")))
+ .WaitAsync(Watchdog, Ct);
+
+ // The pump survived the throw, re-baselined, and keeps delivering.
+ await client.PumpAsync(ChunkFrom(RestartedInstanceId, 42, 45, ("dev1_pos", "12.5"))).WaitAsync(Watchdog, Ct);
+
+ driver.SampleStreamTask!.IsCompleted.ShouldBeFalse();
+ seen.Where(e => e.FullReference == "dev1_pos").Last().Snapshot.Value.ShouldBe(12.5d);
+ }
+
+ // ---- IHostConnectivityProbe ----
+
+ ///
+ /// One host per Agent, named by the authored AgentUri — that is the identity the Admin
+ /// dashboard shows and the key Core scopes its Bad-quality fan-out by. Before the first tick
+ /// the honest answer is : the probe loop is the sole writer of
+ /// this field, and it has not run yet.
+ ///
+ [Fact]
+ public async Task Host_statuses_report_one_host_named_by_the_agent_uri()
+ {
+ var (driver, _, _) = await ProbingDriverAsync();
+
+ driver.HostName.ShouldBe(AgentUri);
+
+ var status = ((IHostConnectivityProbe)driver).GetHostStatuses().ShouldHaveSingleItem();
+ status.HostName.ShouldBe(AgentUri);
+ status.State.ShouldBe(HostState.Unknown);
+ }
+
+ ///
+ /// The anti-inertness pin for . Every tick must
+ /// reach the Agent. A probe routed through the driver's cached-model accessor
+ /// (GetOrFetchProbeModelAsync) compiles, ships, and — with a cache warmed by
+ /// initialize — never issues a single request, reporting Running forever regardless of what
+ /// the Agent is doing. The call count is the only thing that can tell the two apart.
+ ///
+ [Fact]
+ public async Task Probe_loop_calls_the_agent_on_every_tick()
+ {
+ var (_, client, ticker) = await ProbingDriverAsync();
+
+ // Parked before the first tick: the loop waits, THEN probes, so initialize's own /probe is
+ // the only call so far.
+ client.ProbeCallCount.ShouldBe(1);
+
+ await TickAsync(ticker);
+ client.ProbeCallCount.ShouldBe(2);
+
+ await TickAsync(ticker);
+ client.ProbeCallCount.ShouldBe(3);
+
+ await TickAsync(ticker);
+ client.ProbeCallCount.ShouldBe(4);
+
+ // …and it waits the authored interval between them, rather than a hard-coded cadence.
+ ticker.LastInterval.ShouldBe(AuthoredInterval);
+ }
+
+ ///
+ /// Running ↔ Stopped, and the event fires on the transition only. A driver that raised
+ /// on every tick would emit one event per interval per Agent forever; Core would re-fan Bad
+ /// quality across the host's subtree each time, and an operator watching the feed could not
+ /// tell a new outage from an ongoing one.
+ ///
+ [Fact]
+ public async Task Probe_flips_the_host_on_transition_only()
+ {
+ var (driver, client, ticker) = await ProbingDriverAsync();
+ var seen = RecordHostStatus(driver);
+
+ client.ProbeFailure = new HttpRequestException("agent unreachable");
+
+ await TickAsync(ticker);
+ await TickAsync(ticker);
+
+ var down = seen.ShouldHaveSingleItem();
+ down.HostName.ShouldBe(AgentUri);
+ down.OldState.ShouldBe(HostState.Unknown);
+ down.NewState.ShouldBe(HostState.Stopped);
+ ((IHostConnectivityProbe)driver).GetHostStatuses()[0].State.ShouldBe(HostState.Stopped);
+
+ client.ProbeFailure = null;
+
+ await TickAsync(ticker);
+ await TickAsync(ticker);
+
+ seen.Count.ShouldBe(2);
+ var up = seen.Last();
+ up.OldState.ShouldBe(HostState.Stopped);
+ up.NewState.ShouldBe(HostState.Running);
+ ((IHostConnectivityProbe)driver).GetHostStatuses()[0].State.ShouldBe(HostState.Running);
+ }
+
+ ///
+ /// The probe is a liveness check, never a cache writer. Publishing its answer into
+ /// AgentSession.ProbeModel would put a second writer on the field the lifecycle owns
+ /// under a compare-and-swap, and would silently re-warm a cache that
+ /// FlushOptionalCachesAsync (or an Agent restart) deliberately dropped.
+ ///
+ [Fact]
+ public async Task Probe_ticks_never_publish_into_the_session_probe_cache()
+ {
+ var (driver, client, ticker) = await ProbingDriverAsync();
+ var original = driver.CachedProbeModel.ShouldNotBeNull();
+
+ // The Agent now answers /probe with a different model. Nothing the connectivity probe does
+ // may let that model reach the driver's cache.
+ client.Probe = OtherModel();
+
+ await TickAsync(ticker);
+ await TickAsync(ticker);
+
+ driver.CachedProbeModel.ShouldBeSameAs(original);
+ driver.GetMemoryFootprint().ShouldBeGreaterThan(0);
+
+ // …and a flushed cache stays flushed until a real consumer asks for it.
+ await driver.FlushOptionalCachesAsync(Ct);
+ await TickAsync(ticker);
+ driver.CachedProbeModel.ShouldBeNull();
+ }
+
+ ///
+ /// Host connectivity is deliberately NOT an input to DriverHealth. The two real
+ /// data paths (/current reads, the /sample pump) own health and will report the
+ /// same outage with the failure that actually mattered. The probe's deadline
+ /// (Probe.Timeout, 2 s) is far tighter than RequestTimeoutMs and its request is
+ /// the largest document the Agent serves, so letting it degrade the driver would flap
+ /// a perfectly working instance on a slow-but-alive Agent.
+ ///
+ [Fact]
+ public async Task An_unreachable_host_does_not_degrade_driver_health()
+ {
+ var (driver, client, ticker) = await ProbingDriverAsync();
+ client.ProbeFailure = new HttpRequestException("agent unreachable");
+
+ await TickAsync(ticker);
+
+ ((IHostConnectivityProbe)driver).GetHostStatuses()[0].State.ShouldBe(HostState.Stopped);
+ driver.GetHealth().State.ShouldBe(DriverState.Healthy);
+
+ // …and the converse: a reachable host does not clear a genuinely failing read path either.
+ client.ProbeFailure = null;
+ client.CurrentFailure = new HttpRequestException("connection refused");
+ await driver.ReadAsync(["dev1_pos"], Ct);
+ driver.GetHealth().State.ShouldBe(DriverState.Degraded);
+
+ await TickAsync(ticker);
+
+ ((IHostConnectivityProbe)driver).GetHostStatuses()[0].State.ShouldBe(HostState.Running);
+ driver.GetHealth().State.ShouldBe(DriverState.Degraded);
+ }
+
+ ///
+ /// Probe.Enabled = false means no loop, no scheduler wait, and not one extra request.
+ /// An operator who turned probing off must not still be paying for it.
+ ///
+ [Fact]
+ public async Task Probe_disabled_starts_no_loop_and_makes_no_calls()
+ {
+ var ticker = new ManualTicker();
+ var (driver, client) = await InitializedDriverAsync(options: Opts(Probing(enabled: false)), ticker: ticker);
+
+ driver.ProbeLoopTask.ShouldBeNull();
+ ticker.Waits.ShouldBe(0);
+ client.ProbeCallCount.ShouldBe(1); // the initialize prime, and nothing else
+
+ // The capability is still implemented — it just has nothing to report.
+ ((IHostConnectivityProbe)driver).GetHostStatuses()
+ .ShouldHaveSingleItem().State.ShouldBe(HostState.Unknown);
+ }
+
+ ///
+ /// Teardown stops the timer and waits for it. A loop left running would keep dialling an
+ /// Agent through a client its owner has already disposed — the "torn down but still
+ /// reporting" shape the rest of this driver's lifecycle is written to prevent.
+ ///
+ [Fact]
+ public async Task Shutdown_stops_the_probe_loop_and_makes_no_further_calls()
+ {
+ var (driver, client, ticker) = await ProbingDriverAsync();
+ await TickAsync(ticker);
+ var loop = driver.ProbeLoopTask.ShouldNotBeNull();
+
+ await driver.ShutdownAsync(Ct).WaitAsync(Watchdog, Ct);
+
+ loop.IsCompleted.ShouldBeTrue();
+ loop.IsFaulted.ShouldBeFalse();
+ driver.ProbeLoopTask.ShouldBeNull();
+ client.IsDisposed.ShouldBeTrue();
+
+ // The loop is provably gone (ShutdownAsync awaited it), so releasing another tick can reach
+ // nobody — no probe against a disposed client, and no further waits on the scheduler.
+ var calls = client.ProbeCallCount;
+ var waits = ticker.Waits;
+ ticker.Tick();
+
+ client.ProbeCallCount.ShouldBe(calls);
+ ticker.Waits.ShouldBe(waits);
+ }
+
+ ///
+ /// A re-initialize replaces the client, so it must replace the loop that dials it — the old
+ /// one holds a reference to a client the re-initialize may be about to dispose.
+ ///
+ [Fact]
+ public async Task Reinitialize_replaces_the_probe_loop()
+ {
+ var (driver, _, ticker) = await ProbingDriverAsync();
+ var first = driver.ProbeLoopTask.ShouldNotBeNull();
+
+ await driver.ReinitializeAsync("{}", Ct).WaitAsync(Watchdog, Ct);
+
+ first.IsCompleted.ShouldBeTrue();
+ var second = driver.ProbeLoopTask.ShouldNotBeNull();
+ second.ShouldNotBeSameAs(first);
+
+ // The replacement loop is live: it parks, ticks, and probes like the first.
+ await ticker.ParkedAsync().WaitAsync(Watchdog, Ct);
+ await TickAsync(ticker);
+ }
+
+ // ---- operator-authorable zeroes (arch-review 01/S-6) ----
+
+ ///
+ /// arch-review 01/S-6, applied to the two knobs Task 13 is the first consumer of. A
+ /// 0 interval turns the "periodic" probe into an unthrottled hot loop against the
+ /// Agent; a 0 timeout cancels every probe before it can be answered, pinning the host
+ /// to Stopped forever and reporting an outage that does not exist. Both are refused with the
+ /// same RequirePositive posture as the four timing knobs Task 9 guarded.
+ ///
+ [Theory]
+ [InlineData(0, 2000)]
+ [InlineData(-1, 2000)]
+ [InlineData(5000, 0)]
+ [InlineData(5000, -1)]
+ public async Task A_non_positive_probe_interval_or_timeout_is_refused(int intervalMs, int timeoutMs)
+ {
+ var options = Opts(new MTConnectProbeOptions
+ {
+ Enabled = true,
+ Interval = TimeSpan.FromMilliseconds(intervalMs),
+ Timeout = TimeSpan.FromMilliseconds(timeoutMs),
+ });
+
+ var driver = new MTConnectDriver(options, "mt1", _ => CannedAgentClient.FromFixtures());
+
+ await Should.ThrowAsync(() => driver.InitializeAsync("{}", Ct));
+ driver.GetHealth().State.ShouldBe(DriverState.Faulted);
+ }
+
+ ///
+ /// …but only when probing is on. Faulting a deployment over a field the driver never reads
+ /// would be a new way for a deploy to fail with nothing gained; flipping
+ /// Enabled back on is a config edit that re-runs the same validation.
+ ///
+ [Fact]
+ public async Task A_non_positive_probe_interval_is_ignored_when_probing_is_disabled()
+ {
+ var options = Opts(new MTConnectProbeOptions
+ {
+ Enabled = false, Interval = TimeSpan.Zero, Timeout = TimeSpan.Zero,
+ });
+
+ var (driver, _) = await InitializedDriverAsync(options: options);
+
+ driver.GetHealth().State.ShouldBe(DriverState.Healthy);
+ driver.ProbeLoopTask.ShouldBeNull();
+ }
+
+ ///
+ /// A hand-driven replacement for : the
+ /// probe loop parks here, and a test releases exactly one tick at a time. The park signal is
+ /// what makes the suite deterministic — when the loop is parked again, the probe that
+ /// followed the previous release has demonstrably finished.
+ ///
+ ///
+ /// installs the next park signal before releasing the
+ /// current wait, so a loop that races ahead and parks again immediately cannot lose the
+ /// signal a test is about to await.
+ ///
+ private sealed class ManualTicker
+ {
+ private readonly Lock _gate = new();
+ private TaskCompletionSource _parked = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ private TaskCompletionSource _release = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ private int _waits;
+
+ /// How many times the loop has parked in this scheduler.
+ public int Waits => Volatile.Read(ref _waits);
+
+ /// The interval the loop most recently asked to wait for.
+ public TimeSpan LastInterval { get; private set; }
+
+ /// The scheduler seam handed to the driver.
+ public async Task WaitAsync(TimeSpan interval, CancellationToken ct)
+ {
+ TaskCompletionSource release;
+ lock (_gate)
+ {
+ LastInterval = interval;
+ Interlocked.Increment(ref _waits);
+ release = _release;
+ _parked.TrySetResult();
+ }
+
+ await release.Task.WaitAsync(ct).ConfigureAwait(false);
+ }
+
+ /// Completes once the loop is parked in the current wait.
+ public Task ParkedAsync()
+ {
+ lock (_gate)
+ {
+ return _parked.Task;
+ }
+ }
+
+ /// Releases the current wait so exactly one probe runs.
+ public void Tick()
+ {
+ lock (_gate)
+ {
+ var release = _release;
+ _release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ _parked = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ release.TrySetResult();
+ }
+ }
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectObservationIndexTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectObservationIndexTests.cs
new file mode 100644
index 00000000..253a491f
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectObservationIndexTests.cs
@@ -0,0 +1,823 @@
+using System.Globalization;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
+
+///
+/// Task 8 — : the thread-safe
+/// dataItemId → DataValueSnapshot map that /current and the /sample pump
+/// both write into, and the quality mapping that turns the Agent's weakly-typed text into an
+/// OPC UA-shaped snapshot.
+///
+/// Status codes are asserted as literals here on purpose. The production constants
+/// are private, so a wrong numeric value in the driver cannot be masked by both
+/// sides sharing one symbol — the literal is an independent statement of the OPC UA
+/// numeric contract (verified against Opc.Ua.StatusCodes).
+///
+///
+public sealed class MTConnectObservationIndexTests
+{
+ private const string CurrentFixture = "Fixtures/current.xml";
+ private const string UnavailableFixture = "Fixtures/current-unavailable.xml";
+ private const string SampleFixture = "Fixtures/sample.xml";
+
+ private const uint Good = 0x00000000u;
+ private const uint BadNoCommunication = 0x80310000u;
+ private const uint BadWaitingForInitialData = 0x80320000u;
+ private const uint BadNodeIdUnknown = 0x80340000u;
+ private const uint BadOutOfRange = 0x803C0000u;
+ private const uint BadNotSupported = 0x803D0000u;
+ private const uint BadTypeMismatch = 0x80740000u;
+
+ /// Every dataItemId both /current fixtures report.
+ private static readonly string[] AllFixtureDataItemIds =
+ [
+ "dev1_avail",
+ "dev1_pos",
+ "dev1_vibration_ts",
+ "dev1_partcount",
+ "dev1_execution",
+ "dev1_program",
+ "dev1_system_cond"
+ ];
+
+ private static MTConnectStreamsResult ParseFixture(string path) =>
+ MTConnectStreamsParser.Parse(File.ReadAllText(path));
+
+ /// Wraps one synthetic observation in the minimal legal streams result.
+ private static MTConnectStreamsResult Synthetic(
+ string dataItemId, string value, DateTime? timestampUtc = null, bool isStructured = false) =>
+ new(
+ InstanceId: 1L,
+ NextSequence: 2L,
+ FirstSequence: 1L,
+ Observations: [new MTConnectObservation(
+ dataItemId,
+ value,
+ timestampUtc ?? new DateTime(2026, 7, 24, 12, 0, 0, DateTimeKind.Utc),
+ isStructured)]);
+
+ private static MTConnectTagDefinition Tag(string id, DriverDataType type) => new(id, type);
+
+ // --------------------------------------------------------------- UNAVAILABLE => BadNoCommunication
+
+ /// The plan's headline case: the Agent's UNAVAILABLE sentinel is a no-comms quality, not a value.
+ [Fact]
+ public void Unavailable_observation_maps_to_BadNoCommunication_with_null_value()
+ {
+ var idx = new MTConnectObservationIndex();
+ idx.Apply(ParseFixture(UnavailableFixture));
+
+ var snap = idx.Get("dev1_partcount");
+
+ snap.Value.ShouldBeNull();
+ snap.StatusCode.ShouldBe(0x80310000u);
+ }
+
+ ///
+ /// Every one of the seven observations in the all-UNAVAILABLE fixture maps to no-comms —
+ /// including dev1_system_cond, which reaches the index as a CONDITION whose
+ /// <Unavailable/> element name the parser normalized onto the exact literal
+ /// UNAVAILABLE. A case-sensitive miss on that one spelling is the defect this covers.
+ ///
+ [Fact]
+ public void Every_observation_in_the_unavailable_fixture_maps_to_BadNoCommunication()
+ {
+ var idx = new MTConnectObservationIndex();
+ idx.Apply(ParseFixture(UnavailableFixture));
+
+ foreach (var id in AllFixtureDataItemIds)
+ {
+ var snap = idx.Get(id);
+ snap.StatusCode.ShouldBe(BadNoCommunication, $"dataItemId '{id}' should be no-comms");
+ snap.Value.ShouldBeNull($"dataItemId '{id}' must not carry a value when unavailable");
+ }
+ }
+
+ /// An UNAVAILABLE observation still carries the Agent's timestamp — the quality changed, not the clock.
+ [Fact]
+ public void Unavailable_observation_keeps_the_agent_source_timestamp()
+ {
+ var idx = new MTConnectObservationIndex();
+ idx.Apply(ParseFixture(UnavailableFixture));
+
+ var snap = idx.Get("dev1_partcount");
+
+ snap.SourceTimestampUtc.ShouldBe(new DateTime(2026, 7, 24, 12, 10, 0, 300, DateTimeKind.Utc));
+ snap.SourceTimestampUtc!.Value.Kind.ShouldBe(DateTimeKind.Utc);
+ }
+
+ /// The sentinel is matched exactly; a lowercase spelling is a real value, not a no-comms marker.
+ [Theory]
+ [InlineData("UNAVAILABLE", BadNoCommunication)]
+ [InlineData("Unavailable", Good)]
+ [InlineData("unavailable", Good)]
+ public void Unavailable_sentinel_is_matched_on_the_exact_literal(string value, uint expectedStatus)
+ {
+ var idx = new MTConnectObservationIndex([Tag("x", DriverDataType.String)]);
+ idx.Apply(Synthetic("x", value));
+
+ idx.Get("x").StatusCode.ShouldBe(expectedStatus);
+ }
+
+ // ------------------------------------------------------------------------- present values
+
+ /// The plan's second headline case.
+ [Fact]
+ public void Present_value_indexes_good_with_source_timestamp()
+ {
+ var idx = new MTConnectObservationIndex();
+ idx.Apply(ParseFixture(CurrentFixture));
+
+ var snap = idx.Get("dev1_pos");
+
+ snap.StatusCode.ShouldBe(0u);
+ snap.SourceTimestampUtc.ShouldNotBeNull();
+ }
+
+ /// SourceTimestamp is the Agent's observation timestamp, never the indexing clock.
+ [Fact]
+ public void Present_value_carries_the_observation_timestamp_not_the_index_clock()
+ {
+ var idx = new MTConnectObservationIndex();
+ idx.Apply(ParseFixture(CurrentFixture));
+
+ var snap = idx.Get("dev1_pos");
+
+ snap.SourceTimestampUtc.ShouldBe(new DateTime(2026, 7, 24, 12, 0, 0, 200, DateTimeKind.Utc));
+ snap.ServerTimestampUtc.ShouldBeGreaterThan(snap.SourceTimestampUtc!.Value);
+ snap.ServerTimestampUtc.Kind.ShouldBe(DateTimeKind.Utc);
+ }
+
+ /// A Float64-authored SAMPLE coerces to a real double, not the raw text.
+ [Fact]
+ public void Float64_authored_tag_coerces_numeric_text_to_a_double()
+ {
+ var idx = new MTConnectObservationIndex([Tag("dev1_pos", DriverDataType.Float64)]);
+ idx.Apply(ParseFixture(CurrentFixture));
+
+ var snap = idx.Get("dev1_pos");
+
+ snap.StatusCode.ShouldBe(Good);
+ snap.Value.ShouldBeOfType().ShouldBe(123.4567);
+ }
+
+ /// An Int64-authored EVENT (PartCount) coerces to a long. sample.xml is the fixture with a present count.
+ [Fact]
+ public void Int64_authored_tag_coerces_integer_text_to_a_long()
+ {
+ var idx = new MTConnectObservationIndex([Tag("dev1_partcount", DriverDataType.Int64)]);
+ idx.Apply(ParseFixture(SampleFixture));
+
+ var snap = idx.Get("dev1_partcount");
+
+ snap.StatusCode.ShouldBe(Good);
+ snap.Value.ShouldBeOfType().ShouldBe(42L);
+ }
+
+ /// String-authored EVENT/CONDITION observations carry the Agent's text verbatim.
+ [Theory]
+ [InlineData("dev1_execution", "ACTIVE")]
+ [InlineData("dev1_program", "O1234")]
+ [InlineData("dev1_avail", "AVAILABLE")]
+ [InlineData("dev1_system_cond", "Normal")]
+ public void String_authored_tags_carry_the_raw_agent_text(string dataItemId, string expected)
+ {
+ var idx = new MTConnectObservationIndex([Tag(dataItemId, DriverDataType.String)]);
+ idx.Apply(ParseFixture(CurrentFixture));
+
+ var snap = idx.Get(dataItemId);
+
+ snap.StatusCode.ShouldBe(Good);
+ snap.Value.ShouldBeOfType().ShouldBe(expected);
+ }
+
+ /// The remaining scalar coercions, each landing on its exact CLR type.
+ [Theory]
+ [InlineData(DriverDataType.Boolean, "true", true)]
+ [InlineData(DriverDataType.Boolean, "FALSE", false)]
+ [InlineData(DriverDataType.Boolean, "1", true)]
+ [InlineData(DriverDataType.Boolean, "0", false)]
+ [InlineData(DriverDataType.Int16, "-12", (short)-12)]
+ [InlineData(DriverDataType.Int32, "70000", 70000)]
+ [InlineData(DriverDataType.UInt16, "65535", (ushort)65535)]
+ [InlineData(DriverDataType.UInt32, "4000000000", 4000000000u)]
+ [InlineData(DriverDataType.UInt64, "18446744073709551615", 18446744073709551615ul)]
+ [InlineData(DriverDataType.Float32, "1.5", 1.5f)]
+ public void Scalar_coercions_land_on_the_authored_clr_type(
+ DriverDataType type, string text, object expected)
+ {
+ var idx = new MTConnectObservationIndex([Tag("x", type)]);
+ idx.Apply(Synthetic("x", text));
+
+ var snap = idx.Get("x");
+
+ snap.StatusCode.ShouldBe(Good);
+ snap.Value.ShouldBe(expected);
+ }
+
+ /// A DateTime-authored tag parses ISO-8601 and lands on UTC kind.
+ [Fact]
+ public void DateTime_authored_tag_coerces_to_a_utc_datetime()
+ {
+ var idx = new MTConnectObservationIndex([Tag("x", DriverDataType.DateTime)]);
+ idx.Apply(Synthetic("x", "2026-07-24T12:00:00.250Z"));
+
+ var snap = idx.Get("x");
+
+ snap.StatusCode.ShouldBe(Good);
+ var value = snap.Value.ShouldBeOfType();
+ value.ShouldBe(new DateTime(2026, 7, 24, 12, 0, 0, 250, DateTimeKind.Utc));
+ value.Kind.ShouldBe(DateTimeKind.Utc);
+ }
+
+ ///
+ /// Numeric coercion is culture-invariant. On a de-DE machine a naive
+ /// double.TryParse("123.4567") yields 1234567 — a silently wrong value with Good
+ /// quality, the worst possible outcome. Run on a dedicated thread so the culture change
+ /// cannot leak into any parallel test.
+ ///
+ [Fact]
+ public void Numeric_coercion_is_culture_invariant()
+ {
+ Exception? failure = null;
+
+ var thread = new Thread(() =>
+ {
+ try
+ {
+ Thread.CurrentThread.CurrentCulture = new CultureInfo("de-DE");
+
+ var idx = new MTConnectObservationIndex([Tag("dev1_pos", DriverDataType.Float64)]);
+ idx.Apply(ParseFixture(CurrentFixture));
+
+ idx.Get("dev1_pos").Value.ShouldBeOfType().ShouldBe(123.4567);
+ }
+ catch (Exception ex)
+ {
+ failure = ex;
+ }
+ });
+
+ thread.Start();
+ thread.Join();
+
+ failure.ShouldBeNull();
+ }
+
+ // ----------------------------------------------------------------------- coercion failures
+
+ ///
+ /// A vocabulary EVENT authored as a number cannot parse. It must degrade to a Bad-coded
+ /// snapshot — never an exception (the index sits under IReadable), and never a
+ /// silently-wrong zero.
+ ///
+ [Fact]
+ public void Coercion_failure_yields_BadTypeMismatch_and_never_throws()
+ {
+ var idx = new MTConnectObservationIndex([Tag("dev1_execution", DriverDataType.Float64)]);
+
+ Should.NotThrow(() => idx.Apply(ParseFixture(CurrentFixture)));
+
+ var snap = idx.Get("dev1_execution");
+
+ snap.StatusCode.ShouldBe(0x80740000u);
+ snap.Value.ShouldBeNull();
+ }
+
+ /// Non-numeric text is a type mismatch; a number the authored type cannot hold is out of range.
+ [Theory]
+ [InlineData(DriverDataType.Float64, "ACTIVE", BadTypeMismatch)]
+ [InlineData(DriverDataType.Int64, "ACTIVE", BadTypeMismatch)]
+ [InlineData(DriverDataType.Boolean, "ACTIVE", BadTypeMismatch)]
+ [InlineData(DriverDataType.Int16, "99999999999", BadOutOfRange)]
+ [InlineData(DriverDataType.Int32, "3.5", BadOutOfRange)]
+ [InlineData(DriverDataType.UInt32, "-1", BadOutOfRange)]
+ public void Failed_coercion_distinguishes_type_mismatch_from_out_of_range(
+ DriverDataType type, string text, uint expectedStatus)
+ {
+ var idx = new MTConnectObservationIndex([Tag("x", type)]);
+ idx.Apply(Synthetic("x", text));
+
+ var snap = idx.Get("x");
+
+ snap.StatusCode.ShouldBe(expectedStatus);
+ snap.Value.ShouldBeNull();
+ }
+
+ /// A Bad-coded coercion failure still reports when the Agent observed it.
+ [Fact]
+ public void Failed_coercion_keeps_the_agent_source_timestamp()
+ {
+ var idx = new MTConnectObservationIndex([Tag("dev1_execution", DriverDataType.Float64)]);
+ idx.Apply(ParseFixture(CurrentFixture));
+
+ idx.Get("dev1_execution").SourceTimestampUtc
+ .ShouldBe(new DateTime(2026, 7, 24, 12, 0, 0, 500, DateTimeKind.Utc));
+ }
+
+ /// An observation whose text is empty is "the Agent supplied no value" — never a Good empty string.
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void Empty_observation_value_maps_to_BadNoCommunication(string value)
+ {
+ var idx = new MTConnectObservationIndex([Tag("x", DriverDataType.String)]);
+ idx.Apply(Synthetic("x", value));
+
+ var snap = idx.Get("x");
+
+ snap.StatusCode.ShouldBe(BadNoCommunication);
+ snap.Value.ShouldBeNull();
+ }
+
+ // -------------------------------------------------------------------------- unknown lookups
+
+ /// A dataItemId that is neither authored nor ever observed is unknown to this driver.
+ [Fact]
+ public void Unknown_dataItemId_yields_BadNodeIdUnknown()
+ {
+ var idx = new MTConnectObservationIndex();
+ idx.Apply(ParseFixture(CurrentFixture));
+
+ var snap = idx.Get("no_such_data_item");
+
+ snap.StatusCode.ShouldBe(BadNodeIdUnknown);
+ snap.Value.ShouldBeNull();
+ snap.SourceTimestampUtc.ShouldBeNull();
+ }
+
+ ///
+ /// An authored tag the Agent has not reported yet is a different condition from an unknown
+ /// id — it is the standard OPC UA "subscribed, no value yet" state. Collapsing the two
+ /// would tell an operator their correctly-authored tag does not exist.
+ ///
+ [Fact]
+ public void Authored_but_never_observed_tag_yields_BadWaitingForInitialData()
+ {
+ var idx = new MTConnectObservationIndex([Tag("dev1_spindle_speed", DriverDataType.Float64)]);
+ idx.Apply(ParseFixture(CurrentFixture));
+
+ var snap = idx.Get("dev1_spindle_speed");
+
+ snap.StatusCode.ShouldBe(BadWaitingForInitialData);
+ snap.Value.ShouldBeNull();
+ }
+
+ /// An empty / whitespace ref is not a lookup; it degrades rather than throwing.
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void Blank_dataItemId_yields_BadNodeIdUnknown(string dataItemId)
+ {
+ var idx = new MTConnectObservationIndex();
+
+ idx.Get(dataItemId).StatusCode.ShouldBe(BadNodeIdUnknown);
+ }
+
+ // --------------------------------------------------------------------- unauthored observations
+
+ ///
+ /// The Agent streams the whole device, so most observations have no authored tag. They are
+ /// indexed as their raw text under — the type that
+ /// cannot fail to coerce — rather than dropped or coded Bad.
+ ///
+ [Fact]
+ public void Unauthored_observation_is_indexed_as_a_good_string()
+ {
+ var idx = new MTConnectObservationIndex();
+ idx.Apply(ParseFixture(CurrentFixture));
+
+ var snap = idx.Get("dev1_pos");
+
+ snap.StatusCode.ShouldBe(Good);
+ snap.Value.ShouldBeOfType().ShouldBe("123.4567");
+ }
+
+ /// Every fixture observation is indexed, authored or not — nothing is silently dropped.
+ [Fact]
+ public void Every_fixture_observation_is_indexed_even_with_no_authored_tags()
+ {
+ var idx = new MTConnectObservationIndex();
+ idx.Apply(ParseFixture(CurrentFixture));
+
+ idx.Count.ShouldBe(AllFixtureDataItemIds.Length);
+ foreach (var id in AllFixtureDataItemIds)
+ {
+ idx.Get(id).StatusCode.ShouldNotBe(BadNodeIdUnknown, $"'{id}' should have been indexed");
+ }
+ }
+
+ // -------------------------------------------------------------------------------- TIME_SERIES
+
+ ///
+ /// P1 defers TIME_SERIES array materialization to P1.5. An array-authored tag therefore
+ /// reports BadNotSupported rather than pretending: the vector is real data this build
+ /// cannot represent, which is neither a type mismatch nor a comms failure.
+ ///
+ [Fact]
+ public void Time_series_authored_as_an_array_reports_BadNotSupported()
+ {
+ var idx = new MTConnectObservationIndex(
+ [new MTConnectTagDefinition("dev1_vibration_ts", DriverDataType.Float64, IsArray: true, ArrayDim: 10)]);
+ idx.Apply(ParseFixture(CurrentFixture));
+
+ var snap = idx.Get("dev1_vibration_ts");
+
+ snap.StatusCode.ShouldBe(BadNotSupported);
+ snap.Value.ShouldBeNull();
+ }
+
+ ///
+ /// The falsifiable half of the deferral: the vector must never be silently reduced to its
+ /// first element. A Good 1.1 here would be a wrong value an operator would trust.
+ ///
+ [Fact]
+ public void Time_series_vector_is_never_silently_parsed_as_its_first_element()
+ {
+ var idx = new MTConnectObservationIndex(
+ [new MTConnectTagDefinition("dev1_vibration_ts", DriverDataType.Float64, IsArray: true, ArrayDim: 10)]);
+ idx.Apply(ParseFixture(CurrentFixture));
+
+ var snap = idx.Get("dev1_vibration_ts");
+
+ snap.Value.ShouldNotBe(1.1);
+ snap.StatusCode.ShouldNotBe(Good);
+ }
+
+ ///
+ /// No-comms outranks the unsupported shape: an UNAVAILABLE array tag is a genuine, fully
+ /// representable state and must report it rather than the deferral code.
+ ///
+ [Fact]
+ public void Unavailable_wins_over_the_unsupported_array_shape()
+ {
+ var idx = new MTConnectObservationIndex(
+ [new MTConnectTagDefinition("dev1_vibration_ts", DriverDataType.Float64, IsArray: true, ArrayDim: 10)]);
+ idx.Apply(ParseFixture(UnavailableFixture));
+
+ idx.Get("dev1_vibration_ts").StatusCode.ShouldBe(BadNoCommunication);
+ }
+
+ ///
+ /// An unauthored TIME_SERIES observation carries the Agent's exact text as a string. That
+ /// is the raw truth with no interpretation applied — not a scalar guess.
+ ///
+ [Fact]
+ public void Unauthored_time_series_observation_carries_the_verbatim_vector_text()
+ {
+ var idx = new MTConnectObservationIndex();
+ idx.Apply(ParseFixture(CurrentFixture));
+
+ var snap = idx.Get("dev1_vibration_ts");
+
+ snap.StatusCode.ShouldBe(Good);
+ snap.Value.ShouldBeOfType().ShouldBe("1.1 1.2 1.3 1.4 1.5 1.6 1.7 1.8 1.9 2.0");
+ }
+
+ // --------------------------------------------------------------------- DATA_SET / TABLE
+
+ ///
+ /// A structured (DATA_SET / TABLE) observation's real content is its <Entry>
+ /// children, which reach the index concatenated with the keys discarded. It is coded
+ /// BadNotSupported rather than published as that noise — regardless of whether the data item
+ /// is authored, because the shape is a fact about the wire, not about the tag.
+ ///
+ [Fact]
+ public void Structured_observation_reports_BadNotSupported_when_unauthored()
+ {
+ var idx = new MTConnectObservationIndex();
+ idx.Apply(Synthetic("ds1", "12", isStructured: true));
+
+ var snap = idx.Get("ds1");
+
+ snap.StatusCode.ShouldBe(BadNotSupported);
+ snap.Value.ShouldBeNull();
+ }
+
+ /// The String authoring the type inference gives a DATA_SET must not smuggle the noise through.
+ [Fact]
+ public void Structured_observation_is_never_published_as_concatenated_text()
+ {
+ var idx = new MTConnectObservationIndex([Tag("ds1", DriverDataType.String)]);
+ idx.Apply(Synthetic("ds1", "12", isStructured: true));
+
+ var snap = idx.Get("ds1");
+
+ snap.Value.ShouldNotBe("12");
+ snap.Value.ShouldBeNull();
+ snap.StatusCode.ShouldBe(BadNotSupported);
+ }
+
+ /// No-comms outranks the unsupported shape, exactly as it does for a TIME_SERIES array.
+ [Fact]
+ public void Unavailable_wins_over_the_structured_shape()
+ {
+ var idx = new MTConnectObservationIndex([Tag("ds1", DriverDataType.String)]);
+ idx.Apply(Synthetic("ds1", "UNAVAILABLE", isStructured: true));
+
+ idx.Get("ds1").StatusCode.ShouldBe(BadNoCommunication);
+ }
+
+ ///
+ /// End-to-end through the real parser: the entry list that concatenates to the single token
+ /// "12" is caught by the parser's structured flag and never surfaces as a Good value. This is
+ /// the leg that would regress silently if the parser stopped setting the flag.
+ ///
+ [Fact]
+ public void A_parsed_data_set_observation_never_surfaces_as_a_good_value()
+ {
+ const string xml = """
+
+
+
+
+ 1
+ 2
+
+
+
+ """;
+
+ var idx = new MTConnectObservationIndex([Tag("ds1", DriverDataType.String)]);
+ idx.Apply(MTConnectStreamsParser.Parse(xml));
+
+ var snap = idx.Get("ds1");
+
+ snap.StatusCode.ShouldBe(BadNotSupported);
+ snap.Value.ShouldBeNull();
+ }
+
+ /// An ordinary text observation is not structured — the flag must not be over-applied.
+ [Fact]
+ public void An_ordinary_observation_is_not_treated_as_structured()
+ {
+ var idx = new MTConnectObservationIndex([Tag("dev1_program", DriverDataType.String)]);
+ idx.Apply(ParseFixture(CurrentFixture));
+
+ var snap = idx.Get("dev1_program");
+
+ snap.StatusCode.ShouldBe(Good);
+ snap.Value.ShouldBe("O1234");
+ }
+
+ // -------------------------------------------------------------------------------- ordering
+
+ /// Within one Apply, the later observation for a dataItemId wins (Agent order is ascending sequence).
+ [Fact]
+ public void Last_write_wins_within_a_single_apply()
+ {
+ var idx = new MTConnectObservationIndex([Tag("x", DriverDataType.Int64)]);
+
+ idx.Apply(new MTConnectStreamsResult(1L, 4L, 1L, [
+ new MTConnectObservation("x", "1", new DateTime(2026, 7, 24, 12, 0, 0, DateTimeKind.Utc)),
+ new MTConnectObservation("x", "2", new DateTime(2026, 7, 24, 12, 0, 1, DateTimeKind.Utc)),
+ new MTConnectObservation("x", "3", new DateTime(2026, 7, 24, 12, 0, 2, DateTimeKind.Utc)),
+ ]));
+
+ var snap = idx.Get("x");
+
+ snap.Value.ShouldBe(3L);
+ snap.SourceTimestampUtc.ShouldBe(new DateTime(2026, 7, 24, 12, 0, 2, DateTimeKind.Utc));
+ }
+
+ ///
+ /// A <Condition> container may carry several simultaneously-active states
+ /// sharing one dataItemId (a Fault and a Warning at once), which the parser flattens into
+ /// repeats in one result. Within a single document those states are concurrent, so the most
+ /// severe wins — plain last-wins would under-report an active Fault as a Warning purely
+ /// because of element order.
+ ///
+ [Theory]
+ [InlineData("Fault", "Warning", "Fault")]
+ [InlineData("Warning", "Fault", "Fault")]
+ [InlineData("Normal", "Warning", "Warning")]
+ [InlineData("Warning", "Normal", "Warning")]
+ [InlineData("Fault", "Normal", "Fault")]
+ public void Simultaneous_condition_states_keep_the_most_severe_within_one_apply(
+ string first, string second, string expected)
+ {
+ var idx = new MTConnectObservationIndex(
+ [new MTConnectTagDefinition("c", DriverDataType.String, MtCategory: "CONDITION")]);
+
+ idx.Apply(new MTConnectStreamsResult(1L, 3L, 1L, [
+ new MTConnectObservation("c", first, new DateTime(2026, 7, 24, 12, 0, 0, DateTimeKind.Utc)),
+ new MTConnectObservation("c", second, new DateTime(2026, 7, 24, 12, 0, 1, DateTimeKind.Utc)),
+ ]));
+
+ idx.Get("c").Value.ShouldBe(expected);
+ }
+
+ /// An active fault carries more information than "no value"; it outranks UNAVAILABLE.
+ [Fact]
+ public void An_active_fault_outranks_unavailable_within_one_apply()
+ {
+ var idx = new MTConnectObservationIndex(
+ [new MTConnectTagDefinition("c", DriverDataType.String, MtCategory: "CONDITION")]);
+
+ idx.Apply(new MTConnectStreamsResult(1L, 3L, 1L, [
+ new MTConnectObservation("c", "UNAVAILABLE", new DateTime(2026, 7, 24, 12, 0, 0, DateTimeKind.Utc)),
+ new MTConnectObservation("c", "Fault", new DateTime(2026, 7, 24, 12, 0, 1, DateTimeKind.Utc)),
+ ]));
+
+ var snap = idx.Get("c");
+
+ snap.StatusCode.ShouldBe(Good);
+ snap.Value.ShouldBe("Fault");
+ }
+
+ ///
+ /// Worst-of is scoped to ONE document. A later document is the Agent's new statement of the
+ /// condition's state, so a cleared fault must fall back to Normal — a worst-of that spanned
+ /// Applies would latch every fault forever.
+ ///
+ [Fact]
+ public void A_later_apply_clears_a_condition_to_its_new_state()
+ {
+ var idx = new MTConnectObservationIndex(
+ [new MTConnectTagDefinition("c", DriverDataType.String, MtCategory: "CONDITION")]);
+
+ idx.Apply(Synthetic("c", "Fault"));
+ idx.Get("c").Value.ShouldBe("Fault");
+
+ idx.Apply(Synthetic("c", "Normal"));
+
+ idx.Get("c").Value.ShouldBe("Normal");
+ }
+
+ /// Severity reconciliation is for condition words only; repeated ordinary values stay last-wins.
+ [Fact]
+ public void Repeated_non_condition_values_stay_last_wins()
+ {
+ var idx = new MTConnectObservationIndex([Tag("x", DriverDataType.String)]);
+
+ idx.Apply(new MTConnectStreamsResult(1L, 3L, 1L, [
+ new MTConnectObservation("x", "Fault", new DateTime(2026, 7, 24, 12, 0, 0, DateTimeKind.Utc)),
+ new MTConnectObservation("x", "O1234", new DateTime(2026, 7, 24, 12, 0, 1, DateTimeKind.Utc)),
+ ]));
+
+ idx.Get("x").Value.ShouldBe("O1234");
+ }
+
+ /// A later Apply overwrites an earlier one — including Good going to no-comms.
+ [Fact]
+ public void A_later_apply_overwrites_an_earlier_one()
+ {
+ var idx = new MTConnectObservationIndex([Tag("dev1_pos", DriverDataType.Float64)]);
+
+ idx.Apply(ParseFixture(CurrentFixture));
+ idx.Get("dev1_pos").StatusCode.ShouldBe(Good);
+
+ idx.Apply(ParseFixture(UnavailableFixture));
+ idx.Get("dev1_pos").StatusCode.ShouldBe(BadNoCommunication);
+ }
+
+ /// An Apply carrying no observations leaves the index untouched (an idle /sample heartbeat).
+ [Fact]
+ public void An_observation_free_apply_leaves_the_index_untouched()
+ {
+ var idx = new MTConnectObservationIndex([Tag("dev1_pos", DriverDataType.Float64)]);
+ idx.Apply(ParseFixture(CurrentFixture));
+
+ idx.Apply(new MTConnectStreamsResult(1L, 200L, 1L, []));
+
+ idx.Get("dev1_pos").Value.ShouldBe(123.4567);
+ idx.Count.ShouldBe(AllFixtureDataItemIds.Length);
+ }
+
+ /// Clear drops every indexed observation — the Agent-restart re-baseline Task 11 needs.
+ [Fact]
+ public void Clear_drops_every_indexed_observation()
+ {
+ var idx = new MTConnectObservationIndex();
+ idx.Apply(ParseFixture(CurrentFixture));
+ idx.Count.ShouldBe(AllFixtureDataItemIds.Length);
+
+ idx.Clear();
+
+ idx.Count.ShouldBe(0);
+ idx.Get("dev1_pos").StatusCode.ShouldBe(BadNodeIdUnknown);
+ }
+
+ // ------------------------------------------------------------------------ authored-tag map
+
+ ///
+ /// Nothing enforces FullName uniqueness at the options layer, so a duplicate is operator-
+ /// authorable. Last definition wins rather than throwing — refusing to construct would turn
+ /// an authoring slip into a driver that will not start.
+ ///
+ [Fact]
+ public void Duplicate_authored_fullname_takes_the_last_definition()
+ {
+ var idx = new MTConnectObservationIndex([
+ Tag("dev1_pos", DriverDataType.String),
+ Tag("dev1_pos", DriverDataType.Float64),
+ ]);
+ idx.Apply(ParseFixture(CurrentFixture));
+
+ idx.Get("dev1_pos").Value.ShouldBeOfType().ShouldBe(123.4567);
+ }
+
+ /// The index binds to the options' Tags collection, which defaults to empty and is never null.
+ [Fact]
+ public void Index_can_be_built_from_driver_options()
+ {
+ var options = new MTConnectDriverOptions
+ {
+ AgentUri = "http://agent:5000",
+ Tags = [Tag("dev1_pos", DriverDataType.Float64)],
+ };
+
+ var idx = new MTConnectObservationIndex(options.Tags);
+ idx.Apply(ParseFixture(CurrentFixture));
+
+ idx.Get("dev1_pos").Value.ShouldBe(123.4567);
+ }
+
+ // -------------------------------------------------------------------------- thread safety
+
+ ///
+ /// /current (Task 10) and the /sample pump (Task 11) write concurrently while OPC UA reads
+ /// snapshot. Three properties, each stated so it holds at every instant rather than
+ /// only once the race settles: no call throws; no read observes a torn snapshot; and the
+ /// state after a deterministic closing Apply is exact.
+ ///
+ /// Torn-snapshot check. The whole (status, value, source timestamp) triple
+ /// is matched against the closed set of snapshots dev1_pos may legally present —
+ /// never a field in isolation. A Good code beside the unavailable document's timestamp,
+ /// or beside a null value, matches no member and fails.
+ ///
+ ///
+ /// The legal set has THREE members, not two.dev1_pos is authored, so a
+ /// reader that beats the first Apply correctly observes BadWaitingForInitialData —
+ /// the designed "authored, not yet reported" state. An earlier version of this test
+ /// asserted a post-Apply post-condition from inside the race window and failed whenever
+ /// a reader got there first: it was asserting something the index never promised, and it
+ /// verified nothing its name claimed.
+ ///
+ ///
+ [Fact]
+ public async Task Concurrent_applies_and_reads_stay_consistent_and_never_throw()
+ {
+ var idx = new MTConnectObservationIndex([Tag("dev1_pos", DriverDataType.Float64)]);
+ var current = ParseFixture(CurrentFixture);
+ var unavailable = ParseFixture(UnavailableFixture);
+
+ var goodAt = new DateTime(2026, 7, 24, 12, 0, 0, 200, DateTimeKind.Utc);
+ (uint Status, object? Value, DateTime? Source)[] legalSnapshots =
+ [
+ (BadWaitingForInitialData, null, null),
+ (Good, 123.4567, goodAt),
+ (BadNoCommunication, null, new DateTime(2026, 7, 24, 12, 10, 0, 100, DateTimeKind.Utc)),
+ ];
+
+ var reads = 0L;
+ using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(2));
+
+ var writers = Enumerable.Range(0, 4).Select(i => Task.Run(() =>
+ {
+ while (!cts.IsCancellationRequested)
+ {
+ idx.Apply(i % 2 == 0 ? current : unavailable);
+ }
+ })).ToArray();
+
+ var readers = Enumerable.Range(0, 4).Select(_ => Task.Run(() =>
+ {
+ while (!cts.IsCancellationRequested)
+ {
+ var snap = idx.Get("dev1_pos");
+
+ legalSnapshots.ShouldContain((snap.StatusCode, snap.Value, snap.SourceTimestampUtc));
+ Interlocked.Increment(ref reads);
+ }
+ })).ToArray();
+
+ await Task.WhenAll(writers.Concat(readers));
+
+ // Guards a vacuous pass: a reader loop that never ran would have asserted nothing.
+ Interlocked.Read(ref reads).ShouldBeGreaterThan(0L);
+
+ // The racing writers alternate documents, so the state DURING the race is unknowable by
+ // construction — pinning a specific one there is exactly the defect this test used to have.
+ // One deterministic apply, after every writer has stopped, gives an exact end state.
+ idx.Apply(current);
+
+ var final = idx.Get("dev1_pos");
+ final.StatusCode.ShouldBe(Good);
+ final.Value.ShouldBe(123.4567);
+ final.SourceTimestampUtc.ShouldBe(goodAt);
+ idx.Count.ShouldBe(AllFixtureDataItemIds.Length);
+ }
+
+ /// Applying a null result is a programming error, not degradable data.
+ [Fact]
+ public void Apply_rejects_a_null_result()
+ {
+ var idx = new MTConnectObservationIndex();
+
+ Should.Throw(() => idx.Apply(null!));
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectProbeParseTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectProbeParseTests.cs
new file mode 100644
index 00000000..1d285d5c
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectProbeParseTests.cs
@@ -0,0 +1,496 @@
+using System.Diagnostics;
+using System.Net;
+using System.Text;
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
+
+///
+/// Task 6 — /probe parse into the neutral , plus the
+/// leg that feeds it (URL composition + the
+/// per-op deadline), all exercised with no socket.
+///
+public sealed class MTConnectProbeParseTests
+{
+ private const string ProbeFixturePath = "Fixtures/probe.xml";
+
+ /// Every DataItem id the committed fixture declares — all seven, at every depth.
+ private static readonly string[] AllFixtureDataItemIds =
+ [
+ "dev1_avail", // sits directly on , NOT under a component
+ "dev1_pos",
+ "dev1_vibration_ts",
+ "dev1_partcount",
+ "dev1_execution",
+ "dev1_program",
+ "dev1_system_cond" // CONDITION
+ ];
+
+ // ---------------------------------------------------------------- parse: the committed fixture
+
+ [Fact]
+ public async Task Probe_parse_yields_nested_components_and_all_dataitems()
+ {
+ var xml = await File.ReadAllTextAsync(ProbeFixturePath, TestContext.Current.CancellationToken);
+
+ var model = MTConnectProbeParser.Parse(xml);
+
+ model.Devices.ShouldHaveSingleItem();
+ var ids = model.Devices[0].AllDataItems().Select(d => d.Id).ToList();
+ ids.ShouldContain("dev1_system_cond");
+ model.Devices[0].Components.ShouldNotBeEmpty(); // nesting preserved
+ }
+
+ [Fact]
+ public async Task Probe_parse_finds_every_dataitem_including_the_device_level_one()
+ {
+ var model = MTConnectProbeParser.Parse(await ReadFixtureAsync());
+
+ var ids = model.Devices[0].AllDataItems().Select(d => d.Id).ToList();
+
+ // All seven — a walker that only recurses silently drops dev1_avail.
+ ids.ShouldBe(AllFixtureDataItemIds, ignoreOrder: true);
+ model.Devices[0].DataItems.Select(d => d.Id).ShouldBe(["dev1_avail"]);
+ }
+
+ [Fact]
+ public async Task Probe_parse_reads_the_device_identity()
+ {
+ var device = MTConnectProbeParser.Parse(await ReadFixtureAsync()).Devices[0];
+
+ device.Id.ShouldBe("dev1");
+ device.Name.ShouldBe("VMC-3Axis");
+ }
+
+ [Fact]
+ public async Task Probe_parse_preserves_the_component_nesting_chain()
+ {
+ var device = MTConnectProbeParser.Parse(await ReadFixtureAsync()).Devices[0];
+
+ // Device -> Controller -> Path. The element names are the component TYPES (there is no
+ // literal element in MTConnect Devices XML).
+ var controller = device.Components.ShouldHaveSingleItem();
+ controller.Id.ShouldBe("dev1_controller");
+ controller.Name.ShouldBe("controller");
+ controller.DataItems.ShouldBeEmpty();
+
+ var path = controller.Components.ShouldHaveSingleItem();
+ path.Id.ShouldBe("dev1_path");
+ path.Name.ShouldBe("path");
+ path.Components.ShouldBeEmpty();
+ path.DataItems.Select(d => d.Id).ShouldBe(
+ ["dev1_pos", "dev1_vibration_ts", "dev1_partcount", "dev1_execution", "dev1_program", "dev1_system_cond"]);
+ }
+
+ [Fact]
+ public async Task Probe_parse_round_trips_every_dataitem_attribute()
+ {
+ var items = MTConnectProbeParser.Parse(await ReadFixtureAsync())
+ .Devices[0].AllDataItems().ToDictionary(d => d.Id);
+
+ var pos = items["dev1_pos"];
+ pos.Category.ShouldBe("SAMPLE");
+ pos.Type.ShouldBe("POSITION");
+ pos.SubType.ShouldBe("ACTUAL");
+ pos.Units.ShouldBe("MILLIMETER");
+ pos.Representation.ShouldBeNull();
+ pos.SampleCount.ShouldBeNull();
+
+ var timeSeries = items["dev1_vibration_ts"];
+ timeSeries.Category.ShouldBe("SAMPLE");
+ timeSeries.Type.ShouldBe("PATH_FEEDRATE");
+ timeSeries.Representation.ShouldBe("TIME_SERIES");
+ timeSeries.SampleCount.ShouldBe(10);
+ timeSeries.Units.ShouldBe("MILLIMETER/SECOND");
+
+ var condition = items["dev1_system_cond"];
+ condition.Category.ShouldBe("CONDITION");
+ condition.Type.ShouldBe("SYSTEM");
+
+ var availability = items["dev1_avail"];
+ availability.Category.ShouldBe("EVENT");
+ availability.Type.ShouldBe("AVAILABILITY");
+ availability.Name.ShouldBeNull();
+ availability.SubType.ShouldBeNull();
+ availability.Units.ShouldBeNull();
+ availability.Representation.ShouldBeNull();
+ availability.SampleCount.ShouldBeNull();
+ }
+
+ [Fact]
+ public async Task Probe_parse_from_a_stream_matches_the_string_overload()
+ {
+ await using var stream = File.OpenRead(ProbeFixturePath);
+
+ var fromStream = MTConnectProbeParser.Parse(stream);
+
+ // Records compare their IReadOnlyList members by reference, so compare the flattened
+ // content rather than the graphs themselves.
+ var fromString = MTConnectProbeParser.Parse(await ReadFixtureAsync());
+ fromStream.Devices.Select(d => d.Id).ShouldBe(fromString.Devices.Select(d => d.Id));
+ fromStream.Devices[0].AllDataItems().ShouldBe(fromString.Devices[0].AllDataItems());
+ fromStream.Devices[0].Components[0].Components[0].Id.ShouldBe("dev1_path");
+ }
+
+ // ------------------------------------------------------- parse: not shaped to the 1.3 fixture
+
+ [Theory]
+ [InlineData("urn:mtconnect.org:MTConnectDevices:1.3")]
+ [InlineData("urn:mtconnect.org:MTConnectDevices:1.5")]
+ [InlineData("urn:mtconnect.org:MTConnectDevices:2.0")]
+ [InlineData("")] // no namespace at all
+ public void Probe_parse_is_agnostic_to_the_document_namespace_version(string namespaceUri)
+ {
+ var xmlns = namespaceUri.Length == 0 ? string.Empty : $" xmlns=\"{namespaceUri}\"";
+ var xml = $"""
+
+
+
+
+
+
+
+
+
+ """;
+
+ var model = MTConnectProbeParser.Parse(xml);
+
+ model.Devices.ShouldHaveSingleItem().AllDataItems().Select(d => d.Id).ShouldBe(["d_avail"]);
+ }
+
+ [Fact]
+ public void Probe_parse_recurses_components_to_arbitrary_depth()
+ {
+ // Four levels deep, and each level carries its own data item — deeper than the fixture, and
+ // with component element names the parser has never seen.
+ const string Xml = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """;
+
+ var device = MTConnectProbeParser.Parse(Xml).Devices[0];
+
+ device.AllDataItems().Select(d => d.Id).ShouldBe(["l0", "l1", "l2", "l3"]);
+ device.Components[0].Components[0].Components[0].Id.ShouldBe("m");
+ }
+
+ [Fact]
+ public void Probe_parse_keeps_a_vendor_extension_component_in_its_own_namespace()
+ {
+ // The shape the parser's ignore-the-namespace rule exists for, and the reason attributes are
+ // read unqualified: the vendor component carries its OWN namespace prefix, while its
+ // standard / children inherit the DEFAULT MTConnect namespace. Matching
+ // components on a fully-qualified XName would silently drop and every data item
+ // beneath it, leaving the browse tree short with no error anywhere.
+ const string Xml = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """;
+
+ var device = MTConnectProbeParser.Parse(Xml).Devices[0];
+
+ var pump = device.Components.ShouldHaveSingleItem();
+ pump.Id.ShouldBe("pump1");
+ pump.Name.ShouldBe("pump");
+ pump.Components.ShouldHaveSingleItem().Id.ShouldBe("imp1");
+ device.AllDataItems().Select(d => d.Id).ShouldBe(["d_avail", "pump_pressure", "imp_rpm"]);
+ device.AllDataItems().Single(d => d.Id == "pump_pressure").Units.ShouldBe("PASCAL");
+ }
+
+ [Fact]
+ public void Probe_parse_reads_every_device_of_a_multi_device_agent()
+ {
+ const string Xml = """
+
+
+
+
+
+
+
+
+
+
+ """;
+
+ var model = MTConnectProbeParser.Parse(Xml);
+
+ model.Devices.Select(d => d.Id).ShouldBe(["agent", "d1"]);
+ }
+
+ // ------------------------------------------------------------------ parse: fails loudly, never
+ // a silently-empty model
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("not xml at all")]
+ [InlineData("")] // truncated mid-document
+ public void Probe_parse_throws_on_malformed_xml(string xml)
+ {
+ Should.Throw(() => MTConnectProbeParser.Parse(xml));
+ }
+
+ [Fact]
+ public void Probe_parse_throws_on_a_non_mtconnect_document()
+ {
+ var ex = Should.Throw(
+ () => MTConnectProbeParser.Parse("404 Not Found"));
+
+ ex.Message.ShouldContain("MTConnectDevices");
+ ex.Message.ShouldContain("html");
+ }
+
+ [Fact]
+ public void Probe_parse_throws_on_an_mtconnect_error_document_and_surfaces_the_agent_error()
+ {
+ // A real Agent answers an unknown device path with an MTConnectError document under HTTP 200.
+ const string Xml = """
+
+
+
+ Could not find the device 'nope'
+
+
+ """;
+
+ var ex = Should.Throw(() => MTConnectProbeParser.Parse(Xml));
+
+ ex.Message.ShouldContain("NO_DEVICE");
+ ex.Message.ShouldContain("Could not find the device 'nope'");
+ }
+
+ [Theory]
+ [InlineData("")] // no
+ [InlineData("")] // empty
+ public void Probe_parse_throws_when_the_document_declares_no_devices(string xml)
+ {
+ Should.Throw(() => MTConnectProbeParser.Parse(xml));
+ }
+
+ [Theory]
+ // DataItem missing its required id / category / type, and a garbage sampleCount.
+ [InlineData("")]
+ [InlineData("")]
+ [InlineData("")]
+ [InlineData("")]
+ public void Probe_parse_throws_on_a_malformed_dataitem(string dataItemXml)
+ {
+ var xml = $"""
+
+ {dataItemXml}
+
+ """;
+
+ Should.Throw(() => MTConnectProbeParser.Parse(xml));
+ }
+
+ [Fact]
+ public void Probe_parse_throws_when_a_device_has_no_id()
+ {
+ const string Xml = """
+
+
+
+ """;
+
+ Should.Throw(() => MTConnectProbeParser.Parse(Xml));
+ }
+
+ [Fact]
+ public void Probe_parse_throws_when_a_component_has_no_id()
+ {
+ const string Xml = """
+
+
+
+ """;
+
+ Should.Throw(() => MTConnectProbeParser.Parse(Xml));
+ }
+
+ // ---------------------------------------------------------------- MTConnectAgentClient.ProbeAsync
+
+ [Fact]
+ public async Task ProbeAsync_requests_the_agent_probe_path_and_returns_the_parsed_model()
+ {
+ var handler = StubHandler.RespondingWith(await ReadFixtureAsync());
+ using var client = new MTConnectAgentClient(new MTConnectDriverOptions { AgentUri = "http://agent:5000" }, handler);
+
+ var model = await client.ProbeAsync(TestContext.Current.CancellationToken);
+
+ handler.LastRequestUri!.AbsoluteUri.ShouldBe("http://agent:5000/probe");
+ model.Devices.ShouldHaveSingleItem().Id.ShouldBe("dev1");
+ }
+
+ [Theory]
+ [InlineData("http://agent:5000", null, "http://agent:5000/probe")]
+ [InlineData("http://agent:5000/", null, "http://agent:5000/probe")]
+ [InlineData("http://agent:5000", "VMC-3Axis", "http://agent:5000/VMC-3Axis/probe")]
+ [InlineData("http://agent:5000/", "VMC 3Axis", "http://agent:5000/VMC%203Axis/probe")]
+ public async Task ProbeAsync_scopes_the_request_to_the_configured_device_name(
+ string agentUri, string? deviceName, string expectedUri)
+ {
+ var handler = StubHandler.RespondingWith(await ReadFixtureAsync());
+ using var client = new MTConnectAgentClient(
+ new MTConnectDriverOptions { AgentUri = agentUri, DeviceName = deviceName }, handler);
+
+ await client.ProbeAsync(TestContext.Current.CancellationToken);
+
+ handler.LastRequestUri!.AbsoluteUri.ShouldBe(expectedUri);
+ }
+
+ [Fact]
+ public async Task ProbeAsync_bounds_a_hung_agent_by_the_configured_request_timeout()
+ {
+ // The R2-01 frozen-peer lesson: a wedged agent must surface as a cancelled call, not a hang.
+ var handler = StubHandler.Hanging();
+ using var client = new MTConnectAgentClient(
+ new MTConnectDriverOptions { AgentUri = "http://agent:5000", RequestTimeoutMs = 50 }, handler);
+
+ var started = Stopwatch.StartNew();
+ var ex = await Should.ThrowAsync(
+ async () => await client.ProbeAsync(TestContext.Current.CancellationToken));
+
+ // A deadline hit must NOT be reported as a plain cancellation — the caller never cancelled.
+ ex.ShouldNotBeAssignableTo();
+ ex.Message.ShouldContain("http://agent:5000/probe");
+ started.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(10));
+ }
+
+ [Fact]
+ public async Task ProbeAsync_honours_the_callers_cancellation_token()
+ {
+ var handler = StubHandler.Hanging();
+ using var client = new MTConnectAgentClient(new MTConnectDriverOptions { AgentUri = "http://agent:5000" }, handler);
+ using var cts = new CancellationTokenSource();
+ await cts.CancelAsync();
+
+ await Should.ThrowAsync(async () => await client.ProbeAsync(cts.Token));
+ }
+
+ [Fact]
+ public async Task ProbeAsync_throws_rather_than_returning_an_empty_model_when_the_agent_errors()
+ {
+ var handler = StubHandler.RespondingWith("nope", HttpStatusCode.ServiceUnavailable);
+ using var client = new MTConnectAgentClient(new MTConnectDriverOptions { AgentUri = "http://agent:5000" }, handler);
+
+ await Should.ThrowAsync(
+ async () => await client.ProbeAsync(TestContext.Current.CancellationToken));
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ public void Ctor_rejects_a_non_positive_request_timeout(int requestTimeoutMs)
+ {
+ // An operator-authorable 0 must never mean "wait forever" (arch-review 01/S-6).
+ Should.Throw(() => new MTConnectAgentClient(
+ new MTConnectDriverOptions { AgentUri = "http://agent:5000", RequestTimeoutMs = requestTimeoutMs }));
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("not a uri")]
+ public void Ctor_rejects_an_unusable_agent_uri(string agentUri)
+ {
+ Should.Throw(() => new MTConnectAgentClient(new MTConnectDriverOptions { AgentUri = agentUri }));
+ }
+
+ [Fact]
+ public void Ctor_opens_no_connection()
+ {
+ // The universal browser's throwaway-instance CanBrowse pattern depends on this.
+ var handler = StubHandler.Hanging();
+
+ using var client = new MTConnectAgentClient(new MTConnectDriverOptions { AgentUri = "http://agent:5000" }, handler);
+
+ handler.RequestCount.ShouldBe(0);
+ }
+
+ private static Task ReadFixtureAsync() =>
+ File.ReadAllTextAsync(ProbeFixturePath, TestContext.Current.CancellationToken);
+
+ /// A canned — no socket is ever opened.
+ private sealed class StubHandler : HttpMessageHandler
+ {
+ private readonly string? _body;
+ private readonly HttpStatusCode _status;
+ private readonly bool _hang;
+
+ private StubHandler(string? body, HttpStatusCode status, bool hang)
+ {
+ _body = body;
+ _status = status;
+ _hang = hang;
+ }
+
+ public Uri? LastRequestUri { get; private set; }
+
+ public int RequestCount { get; private set; }
+
+ public static StubHandler RespondingWith(string body, HttpStatusCode status = HttpStatusCode.OK) =>
+ new(body, status, hang: false);
+
+ public static StubHandler Hanging() => new(null, HttpStatusCode.OK, hang: true);
+
+ protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ LastRequestUri = request.RequestUri;
+ RequestCount++;
+
+ if (_hang)
+ {
+ await Task.Delay(Timeout.Infinite, cancellationToken);
+ }
+
+ return new HttpResponseMessage(_status)
+ {
+ Content = new StringContent(_body ?? string.Empty, Encoding.UTF8, "text/xml")
+ };
+ }
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectRawTagBindingTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectRawTagBindingTests.cs
new file mode 100644
index 00000000..9f12645e
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectRawTagBindingTests.cs
@@ -0,0 +1,458 @@
+using System.Collections.Concurrent;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Commons.Types;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
+
+///
+/// The v3 wire contract: the runtime hands this driver its authored tags as a RawTags
+/// array of (RawPath identity + driver TagConfig blob) and then
+/// reads, subscribes, and routes published values by RawPath — never by the driver's own
+/// internal address (here, the MTConnect DataItem id).
+///
+///
+///
+/// Every config in this file is built by the real
+/// — the same merge
+/// DeploymentArtifact.TryReadSpec runs to produce a
+/// DriverInstanceSpec.DriverConfig. A hand-written JSON literal would only prove the
+/// driver binds the shape the test author imagined; this proves it binds the shape the
+/// deploy artifact actually produces, including the Pascal-case property names
+/// RawTagEntry serialises under and the TagConfig blob arriving as an escaped
+/// string rather than a nested object.
+///
+///
+/// The load-bearing assertion is the reference on the event, not the value.
+/// DriverHostActor routes a published value by (DriverInstanceId, RawPath)
+/// against a NodeId table it built from the same RawPaths; a driver that published a
+/// perfectly correct value under its own dataItemId would miss that table on every tick and
+/// the value would be dropped silently — with no error anywhere and every unit test green.
+///
+///
+public sealed class MTConnectRawTagBindingTests
+{
+ private const string AgentUri = "http://fixture-agent:5000";
+
+ /// The DataItem ids the canned Fixtures/ documents report.
+ private const string PositionDataItemId = "dev1_pos";
+ private const string ExecutionDataItemId = "dev1_execution";
+ private const string PartCountDataItemId = "dev1_partcount";
+
+ /// The RawPaths a deployed /raw tree would give those data items.
+ private const string PositionRawPath = "Plant/MTConnect/dev1/Position";
+ private const string ExecutionRawPath = "Plant/MTConnect/dev1/Execution";
+ private const string PartCountRawPath = "Plant/MTConnect/dev1/PartCount";
+
+ /// The instanceId every canned fixture shares.
+ private const long FixtureInstanceId = 1655000000L;
+
+ /// The cursor Fixtures/current.xml primes the pump with.
+ private const long PrimedNextSequence = 108L;
+
+ private const uint Good = 0x00000000u;
+ private const uint BadNoCommunication = 0x80310000u;
+ private const uint BadNodeIdUnknown = 0x80340000u;
+ private const uint BadTypeMismatch = 0x80740000u;
+
+ private static readonly DateTime ObservedAt = new(2026, 7, 24, 12, 30, 0, DateTimeKind.Utc);
+
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ ///
+ /// The constructor options a spawned driver starts from: the endpoint only. Everything the
+ /// data plane binds arrives in the merged config document, exactly as it does in production.
+ ///
+ private static MTConnectDriverOptions Bare() => new() { AgentUri = AgentUri };
+
+ ///
+ /// Builds the merged DriverConfig for one MTConnect driver instance the way the deploy
+ /// artifact does: driver-level config + the device's DeviceConfig + the authored raw
+ /// tags injected as the RawTags array.
+ ///
+ private static string MergedConfig(params RawTagEntry[] rawTags) =>
+ DriverDeviceConfigMerger.Merge(
+ $$"""{"AgentUri":"{{AgentUri}}"}""",
+ [new DriverDeviceConfigMerger.DeviceRow("dev1", "{}")],
+ rawTags);
+
+ /// One authored raw tag: a RawPath bound to a DataItem id, typed for coercion.
+ private static RawTagEntry Entry(string rawPath, string dataItemId, string? driverDataType = null) =>
+ new(
+ rawPath,
+ driverDataType is null
+ ? $$"""{"fullName":"{{dataItemId}}"}"""
+ : $$"""{"fullName":"{{dataItemId}}","driverDataType":"{{driverDataType}}"}""",
+ WriteIdempotent: false,
+ DeviceName: "dev1");
+
+ private static MTConnectStreamsResult Chunk(
+ long firstSequence, long nextSequence, params (string Id, string Value)[] observations) =>
+ new(
+ FixtureInstanceId,
+ nextSequence,
+ firstSequence,
+ [.. observations.Select(o => new MTConnectObservation(o.Id, o.Value, ObservedAt))]);
+
+ private static IReadOnlyList For(
+ ConcurrentQueue seen, string reference) =>
+ [.. seen.Where(e => e.FullReference == reference)];
+
+ private static ConcurrentQueue Record(MTConnectDriver driver)
+ {
+ var seen = new ConcurrentQueue();
+ driver.OnDataChange += (_, e) => seen.Enqueue(e);
+
+ return seen;
+ }
+
+ private static async Task<(MTConnectDriver Driver, CannedAgentClient Client)> DeployedAsync(
+ params RawTagEntry[] rawTags)
+ {
+ var agent = CannedAgentClient.FromFixtures();
+ var driver = new MTConnectDriver(Bare(), "mt1", _ => agent);
+ await driver.InitializeAsync(MergedConfig(rawTags), Ct);
+
+ return (driver, agent);
+ }
+
+ // ---- the production data plane: subscribe by RawPath, publish by RawPath ----
+
+ ///
+ /// The whole blocker in one case: a deployed driver is subscribed by RawPath (that is
+ /// what DriverInstanceActor.HandleSubscribeAsync passes, and what
+ /// DriverHostActor's NodeId table is keyed by) and must answer under that same
+ /// RawPath — both for the initial value and for every streamed chunk.
+ ///
+ [Fact]
+ public async Task Subscribing_by_raw_path_publishes_under_the_raw_path()
+ {
+ var (driver, agent) = await DeployedAsync(
+ Entry(PositionRawPath, PositionDataItemId, nameof(DriverDataType.Float64)));
+ var seen = Record(driver);
+
+ await driver.SubscribeAsync([PositionRawPath], TimeSpan.FromMilliseconds(50), Ct);
+
+ // Initial data, out of the priming /current — under the RawPath, not the dataItemId.
+ var initial = For(seen, PositionRawPath).ShouldHaveSingleItem();
+ initial.Snapshot.StatusCode.ShouldBe(Good);
+ initial.Snapshot.Value.ShouldBe(123.4567d);
+
+ await agent.PumpAsync(
+ Chunk(PrimedNextSequence, PrimedNextSequence + 1, (PositionDataItemId, "201.5000")));
+
+ var streamed = For(seen, PositionRawPath);
+ streamed.Count.ShouldBe(2);
+ streamed[1].Snapshot.StatusCode.ShouldBe(Good);
+ streamed[1].Snapshot.Value.ShouldBe(201.5d);
+
+ // …and NOTHING is ever published under the driver-internal dataItemId: a value routed by that
+ // reference misses DriverHostActor's NodeId table and is dropped without a trace.
+ For(seen, PositionDataItemId).ShouldBeEmpty();
+
+ await driver.ShutdownAsync(Ct);
+ }
+
+ ///
+ /// The coercion type must survive the new binding path. The TagConfig blob's
+ /// driverDataType is what tells the observation index to publish 201.5 as a
+ /// rather than the Agent's raw text — losing it would turn every value
+ /// into a Good-coded string, which the OPC UA node (typed Double) cannot carry.
+ ///
+ [Fact]
+ public async Task Raw_tag_config_carries_the_coercion_type_into_the_index()
+ {
+ var (driver, _) = await DeployedAsync(
+ Entry(PositionRawPath, PositionDataItemId, nameof(DriverDataType.Float64)),
+ Entry(ExecutionRawPath, ExecutionDataItemId, nameof(DriverDataType.String)));
+ var seen = Record(driver);
+
+ await driver.SubscribeAsync([PositionRawPath, ExecutionRawPath], TimeSpan.FromMilliseconds(50), Ct);
+
+ For(seen, PositionRawPath)[0].Snapshot.Value.ShouldBeOfType();
+ For(seen, ExecutionRawPath)[0].Snapshot.Value.ShouldBeOfType();
+
+ await driver.ShutdownAsync(Ct);
+ }
+
+ ///
+ /// keys by RawPath too. The server never calls it (the data
+ /// plane is subscribe-only), but the driver CLI does, and a read that answered
+ /// BadNodeIdUnknown for a tag the subscription serves happily would be a lie about the
+ /// deployment.
+ ///
+ [Fact]
+ public async Task Read_resolves_raw_paths()
+ {
+ var (driver, _) = await DeployedAsync(
+ Entry(PositionRawPath, PositionDataItemId, nameof(DriverDataType.Float64)),
+ Entry(PartCountRawPath, PartCountDataItemId, nameof(DriverDataType.Int32)));
+
+ var values = await driver.ReadAsync([PositionRawPath, PartCountRawPath, "Plant/MTConnect/dev1/Nope"], Ct);
+
+ values.Count.ShouldBe(3);
+ values[0].StatusCode.ShouldBe(Good);
+ values[0].Value.ShouldBe(123.4567d);
+
+ // Authored + bound, but the Agent reports it UNAVAILABLE in the fixture.
+ values[1].StatusCode.ShouldBe(BadNoCommunication);
+
+ // Not in RawTags at all: a miss is a miss, surfaced as Bad — never a throw.
+ values[2].StatusCode.ShouldBe(BadNodeIdUnknown);
+
+ await driver.ShutdownAsync(Ct);
+ }
+
+ ///
+ /// The AdminUI's typed editor (MTConnectTagConfigModel) writes the coercion type under
+ /// dataType, not driverDataType. Reading only the driver's own spelling would
+ /// make every editor-authored tag fall back to String — the editor and the runtime disagreeing
+ /// about the type of every value, with nothing anywhere reporting it.
+ ///
+ [Fact]
+ public async Task The_admin_ui_datatype_spelling_reaches_the_coercion()
+ {
+ var (driver, _) = await DeployedAsync(
+ new RawTagEntry(
+ PositionRawPath,
+ """{"fullName":"dev1_pos","dataType":"Float64","mtCategory":"SAMPLE","mtType":"POSITION"}""",
+ WriteIdempotent: false,
+ DeviceName: "dev1"));
+ var seen = Record(driver);
+
+ await driver.SubscribeAsync([PositionRawPath], TimeSpan.FromMilliseconds(50), Ct);
+
+ For(seen, PositionRawPath)[0].Snapshot.Value.ShouldBe(123.4567d);
+
+ await driver.ShutdownAsync(Ct);
+ }
+
+ ///
+ /// A browse-committed tag's blob carries ONLY the address (RawBrowseCommitMapper has no
+ /// MTConnect branch, so it writes the generic {"address": id} shape) — no type at all.
+ /// The driver falls back to the Agent's own /probe declaration, so a POSITION SAMPLE
+ /// publishes as a rather than as the Agent's raw text under a
+ /// numerically-typed OPC UA node.
+ ///
+ [Fact]
+ public async Task A_typeless_blob_takes_the_type_the_agent_declares()
+ {
+ var (driver, _) = await DeployedAsync(
+ new RawTagEntry(PositionRawPath, """{"address":"dev1_pos"}""", false, "dev1"));
+ var seen = Record(driver);
+
+ await driver.SubscribeAsync([PositionRawPath], TimeSpan.FromMilliseconds(50), Ct);
+
+ var initial = For(seen, PositionRawPath)[0];
+ initial.Snapshot.StatusCode.ShouldBe(Good);
+ initial.Snapshot.Value.ShouldBeOfType().ShouldBe(123.4567d);
+
+ await driver.ShutdownAsync(Ct);
+ }
+
+ ///
+ /// A