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 no JsonStringEnumConverter: 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 name or 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 instanceId and 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 + /// /readyz 503, 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 /sample interval query parameter in ms. Must be > 0. + public int SampleIntervalMs { get; set; } = 1_000; + /// The /sample count query parameter. Must be > 0. + public int SampleCount { get; set; } = 1_000; + /// The /sample heartbeat 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 /sample interval query parameter in ms. + public int? SampleIntervalMs { get; init; } + /// The /sample count query parameter. + public int? SampleCount { get; init; } + /// The /sample heartbeat 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 + +
+
+ + +
The Agent DataItem's id attribute — the driver's read/subscribe key.
+
+
+ + +
Overrides the type the browse picker inferred. MTConnect is text on the wire — a wrong numeric type reads as Bad quality.
+
+
+ +@* Probe-sourced device-model metadata — display only. *@ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
From the Agent's /probe device model — not editable here. Re-pick the tag in the browser to refresh it.
+
+
+ +@* Author's own notes — not consumed by the driver or the runtime. *@ +
+
+ + +
+
+ + +
+
+ +@if (_m.Validate() is { } error) +{ +
@error
+} + +@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 entry supplies the coercion type for a raw tag + /// whose blob declares none — the middle rung of the documented precedence, and what keeps a + /// driver authored through both surfaces coherent. + /// + [Fact] + public async Task Tags_supply_the_type_for_a_raw_tag_whose_blob_declares_none() + { + var agent = CannedAgentClient.FromFixtures(); + + // dev1_execution is an EVENT the probe model would infer as String; the Tags entry overrides + // that with Reference (a type nothing could infer), so the assertion can only pass if the + // Tags entry — not the inference — supplied it. + var driver = new MTConnectDriver( + new MTConnectDriverOptions + { + AgentUri = AgentUri, + Tags = [new MTConnectTagDefinition(ExecutionDataItemId, DriverDataType.Int32)], + }, + "mt1", + _ => agent); + + // A config document with a body REPLACES the constructor options, so the Tags entry has to + // travel in it too — this is the shape a driver authored through both surfaces deploys as. + await driver.InitializeAsync( + $$""" + {"AgentUri":"{{AgentUri}}", + "Tags":[{"FullName":"{{ExecutionDataItemId}}","DriverDataType":"Int32"}], + "RawTags":[{"RawPath":"{{ExecutionRawPath}}","TagConfig":"{\"fullName\":\"{{ExecutionDataItemId}}\"}","WriteIdempotent":false,"DeviceName":"dev1"}]} + """, + Ct); + + var seen = Record(driver); + await driver.SubscribeAsync([ExecutionRawPath], TimeSpan.FromMilliseconds(50), Ct); + + // The fixture reports dev1_execution as "ACTIVE" — not an Int32, so the Int32 the Tags entry + // declared is what makes this a type mismatch instead of a Good string. + For(seen, ExecutionRawPath)[0].Snapshot.StatusCode.ShouldBe(BadTypeMismatch); + + await driver.ShutdownAsync(Ct); + } + + /// + /// RawTags and Tags may both name the same DataItem. The RawTags blob is the + /// deploy-time authority and its declared type wins; the dataItemId-keyed reference keeps + /// working alongside the RawPath, so a CLI session against a deployed driver still resolves. + /// + [Fact] + public async Task Both_collections_together_bind_both_references_with_rawtags_winning_the_type() + { + var agent = CannedAgentClient.FromFixtures(); + var driver = new MTConnectDriver(Bare(), "mt1", _ => agent); + + await driver.InitializeAsync( + $$""" + {"AgentUri":"{{AgentUri}}", + "Tags":[{"FullName":"{{PositionDataItemId}}","DriverDataType":"String"}], + "RawTags":[{"RawPath":"{{PositionRawPath}}","TagConfig":"{\"fullName\":\"{{PositionDataItemId}}\",\"driverDataType\":\"Float64\"}","WriteIdempotent":false,"DeviceName":"dev1"}]} + """, + Ct); + + var seen = Record(driver); + await driver.SubscribeAsync( + [PositionRawPath, PositionDataItemId], TimeSpan.FromMilliseconds(50), Ct); + + // The RawTags blob's Float64 beat the Tags entry's String — for BOTH references, because + // there is one index and one definition per DataItem. + For(seen, PositionRawPath)[0].Snapshot.Value.ShouldBe(123.4567d); + For(seen, PositionDataItemId)[0].Snapshot.Value.ShouldBe(123.4567d); + + // …and a streamed chunk raises BOTH, once each. + await agent.PumpAsync( + Chunk(PrimedNextSequence, PrimedNextSequence + 1, (PositionDataItemId, "201.5000"))); + + For(seen, PositionRawPath).Count.ShouldBe(2); + For(seen, PositionDataItemId).Count.ShouldBe(2); + + await driver.ShutdownAsync(Ct); + } + + /// + /// One DataItem may back several authored raw tags (the same machine signal projected into two + /// places in the /raw tree). Each is its own OPC UA node and each must receive the + /// value — a one-to-one map would silently serve only whichever tag was authored last. + /// + [Fact] + public async Task One_data_item_fans_out_to_every_raw_path_bound_to_it() + { + const string secondRawPath = "Plant/MTConnect/dev1/Mirror/Position"; + var (driver, agent) = await DeployedAsync( + Entry(PositionRawPath, PositionDataItemId, nameof(DriverDataType.Float64)), + Entry(secondRawPath, PositionDataItemId, nameof(DriverDataType.Float64))); + var seen = Record(driver); + + await driver.SubscribeAsync( + [PositionRawPath, secondRawPath], TimeSpan.FromMilliseconds(50), Ct); + await agent.PumpAsync( + Chunk(PrimedNextSequence, PrimedNextSequence + 1, (PositionDataItemId, "201.5000"))); + + For(seen, PositionRawPath).Count.ShouldBe(2); + For(seen, secondRawPath).Count.ShouldBe(2); + For(seen, secondRawPath)[1].Snapshot.Value.ShouldBe(201.5d); + + await driver.ShutdownAsync(Ct); + } + + /// + /// A blob that names no DataItem id, or declares a dataType that is not a + /// (including the numerically-serialized-enum trap, where + /// Enum.TryParse would happily take "8" as the member with that ordinal), skips + /// that ONE tag — the rest of the driver deploys and serves normally. + /// + [Theory] + [InlineData("""{"units":"MILLIMETER"}""")] + [InlineData("""{"fullName":"dev1_pos","dataType":"Flot64"}""")] + [InlineData("""{"fullName":"dev1_pos","dataType":"8"}""")] + [InlineData("not json at all")] + public async Task An_unusable_blob_skips_only_its_own_tag(string badBlob) + { + var (driver, _) = await DeployedAsync( + new RawTagEntry("Plant/MTConnect/dev1/Bad", badBlob, false, "dev1"), + Entry(PartCountRawPath, PartCountDataItemId, nameof(DriverDataType.Int32))); + + var values = await driver.ReadAsync(["Plant/MTConnect/dev1/Bad", PartCountRawPath], Ct); + + values[0].StatusCode.ShouldBe(BadNodeIdUnknown); + values[1].StatusCode.ShouldBe(BadNoCommunication); // bound; the Agent reports it UNAVAILABLE + + await driver.ShutdownAsync(Ct); + } + + /// + /// A redeploy that re-homes a tag in the /raw tree changes its RawPath. The driver must + /// rebind to the new one — and stop answering for the old one, which no longer names anything. + /// This is also the ordering pin: the binding is installed with the options, before the index + /// the pump republishes from, so a standing subscription can never be served through one + /// document's RawPaths against another's values. + /// + [Fact] + public async Task Reinitialize_rebinds_raw_paths() + { + const string movedRawPath = "Plant/MTConnect/dev1/Spindle/Position"; + var (driver, agent) = await DeployedAsync( + Entry(PositionRawPath, PositionDataItemId, nameof(DriverDataType.Float64))); + var seen = Record(driver); + + await driver.SubscribeAsync([PositionRawPath, movedRawPath], TimeSpan.FromMilliseconds(50), Ct); + For(seen, movedRawPath)[0].Snapshot.StatusCode.ShouldBe(BadNodeIdUnknown); + + await driver.ReinitializeAsync( + MergedConfig(Entry(movedRawPath, PositionDataItemId, nameof(DriverDataType.Float64))), Ct); + + // The restarted pump republishes every standing reference as its first act. + await agent.PumpAsync( + Chunk(PrimedNextSequence, PrimedNextSequence + 1, (PositionDataItemId, "201.5000"))); + + For(seen, movedRawPath)[^1].Snapshot.Value.ShouldBe(201.5d); + For(seen, PositionRawPath)[^1].Snapshot.StatusCode.ShouldBe(BadNodeIdUnknown); + + await driver.ShutdownAsync(Ct); + } + + /// + /// The factory is the production construction seam (DriverFactoryRegistry calls it with + /// the merged artifact config), so RawTags has to survive that path too — a driver whose + /// RawPaths only bind when constructed by hand is a driver that works in tests and nowhere else. + /// + [Fact] + public void The_factory_binds_raw_tags_from_the_merged_config() + { + var driver = MTConnectDriverFactoryExtensions.CreateInstance( + "mt1", + MergedConfig(Entry(PositionRawPath, PositionDataItemId, nameof(DriverDataType.Float64)))); + + var bound = driver.EffectiveOptions.RawTags.ShouldHaveSingleItem(); + bound.RawPath.ShouldBe(PositionRawPath); + bound.DeviceName.ShouldBe("dev1"); + bound.TagConfig.ShouldContain(PositionDataItemId); + } + + /// + /// A subscribed RawPath with no RawTags entry behind it (an authoring slip, or a tag + /// whose blob would not map) must report Bad and keep serving every other reference — the + /// documented posture: "a miss is a miss". + /// + [Fact] + public async Task An_unmapped_raw_path_reports_bad_and_never_throws() + { + var (driver, agent) = await DeployedAsync( + Entry(PositionRawPath, PositionDataItemId, nameof(DriverDataType.Float64))); + var seen = Record(driver); + + await driver.SubscribeAsync( + [PositionRawPath, "Plant/MTConnect/dev1/Ghost"], TimeSpan.FromMilliseconds(50), Ct); + + For(seen, "Plant/MTConnect/dev1/Ghost")[0].Snapshot.StatusCode.ShouldBe(BadNodeIdUnknown); + + // The bound reference is unaffected — the miss did not poison the batch or the stream. + await agent.PumpAsync( + Chunk(PrimedNextSequence, PrimedNextSequence + 1, (PositionDataItemId, "201.5000"))); + For(seen, PositionRawPath).Count.ShouldBe(2); + + await driver.ShutdownAsync(Ct); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectReadTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectReadTests.cs new file mode 100644 index 00000000..ad9288dd --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectReadTests.cs @@ -0,0 +1,456 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests; + +/// +/// Task 10 — 's half: one /current +/// per batch, one snapshot per requested reference in request order, and a Bad code for every +/// shape of failure. Every test runs against ; no socket is +/// opened anywhere in this file. +/// +/// +/// +/// The load-bearing assertions are about arity, order, and not-throwing. The caller +/// (the runtime's dispatch layer) indexes the returned list positionally against the +/// references it asked for, so a length mismatch or a reorder is silent data corruption — +/// every value lands on the wrong tag under Good quality. And every failure this driver can +/// meet at read time (agent down, deadline blown, unparseable answer, unknown id, driver not +/// initialized) must arrive as a Bad-coded snapshot: an exception out of +/// fails the whole batch including the references that +/// were perfectly readable. +/// +/// +/// The one exception that may propagate is genuine caller cancellation, and it is +/// pinned here alongside its opposite (an agent failure under an uncancelled token) — the +/// two are one catch filter apart in the implementation, and getting the filter +/// backwards turns every dead agent into a thrown batch. +/// +/// +public sealed class MTConnectReadTests +{ + private const string AgentUri = "http://fixture-agent:5000"; + + // Canonical Opc.Ua.StatusCodes numerics, restated here so the test asserts the wire value an + // OPC UA client actually sees rather than a driver-private constant it could drift with. + private const uint Good = 0x00000000u; + private const uint BadMask = 0x80000000u; + private const uint BadCommunicationError = 0x80050000u; + private const uint BadNoCommunication = 0x80310000u; + private const uint BadWaitingForInitialData = 0x80320000u; + private const uint BadNodeIdUnknown = 0x80340000u; + private const uint BadNotSupported = 0x803D0000u; + private const uint BadNotConnected = 0x808A0000u; + + /// + /// Authored tags spanning every read outcome the fixtures can produce: a coercible Good + /// value, an UNAVAILABLE one, a TIME_SERIES vector, and one authored id no fixture + /// ever reports. + /// + private static MTConnectDriverOptions Opts(int requestTimeoutMs = 5000) => + new() + { + AgentUri = AgentUri, + RequestTimeoutMs = requestTimeoutMs, + Tags = + [ + new MTConnectTagDefinition("dev1_pos", DriverDataType.Float64), + new MTConnectTagDefinition("dev1_execution", DriverDataType.String), + new MTConnectTagDefinition("dev1_partcount", DriverDataType.Int32), + new MTConnectTagDefinition("dev1_vibration_ts", DriverDataType.Float64, IsArray: true, ArrayDim: 10), + new MTConnectTagDefinition("dev1_never_reported", DriverDataType.Int32), + ], + }; + + 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( + MTConnectDriverOptions? options = null) + { + var (driver, client) = NewDriver(options); + await driver.InitializeAsync("{}", TestContext.Current.CancellationToken); + + return (driver, client); + } + + // ---- arity + order: the contract the caller indexes positionally ---- + + /// The plan's TDD case: one snapshot per ref, in order, absent ⇒ Bad rather than a throw. + [Fact] + public async Task Read_returns_one_snapshot_per_ref_in_order_absent_ref_is_bad() + { + var (driver, _) = await InitializedDriverAsync(); + + var res = await driver.ReadAsync(["dev1_pos", "does-not-exist"], TestContext.Current.CancellationToken); + + res.Count.ShouldBe(2); + res[0].StatusCode.ShouldBe(Good); + (res[1].StatusCode & BadMask).ShouldBe(BadMask); + } + + /// + /// A mixed batch requested in an order that matches neither the document order nor any + /// dictionary enumeration order. This is the assertion that catches a driver that answers + /// with the index's own key order instead of the request's. + /// + [Fact] + public async Task Read_answers_in_request_order_not_document_order() + { + var (driver, _) = await InitializedDriverAsync(); + + var res = await driver.ReadAsync( + ["dev1_program", "dev1_pos", "does-not-exist", "dev1_partcount", "dev1_execution"], + TestContext.Current.CancellationToken); + + res.Count.ShouldBe(5); + + // dev1_program is observed but NOT authored — String is the only coercion that cannot fail. + res[0].StatusCode.ShouldBe(Good); + res[0].Value.ShouldBe("O1234"); + + res[1].StatusCode.ShouldBe(Good); + res[1].Value.ShouldBe(123.4567d); + + res[2].StatusCode.ShouldBe(BadNodeIdUnknown); + + res[3].StatusCode.ShouldBe(BadNoCommunication); + + res[4].StatusCode.ShouldBe(Good); + res[4].Value.ShouldBe("ACTIVE"); + } + + /// + /// A reference repeated in one request gets a snapshot per occurrence. De-duplicating would + /// shorten the list and shift every later reference onto the wrong tag. + /// + [Fact] + public async Task Read_returns_a_snapshot_per_duplicate_occurrence() + { + var (driver, _) = await InitializedDriverAsync(); + + var res = await driver.ReadAsync( + ["dev1_pos", "dev1_execution", "dev1_pos"], TestContext.Current.CancellationToken); + + res.Count.ShouldBe(3); + res[0].StatusCode.ShouldBe(Good); + res[0].Value.ShouldBe(123.4567d); + res[1].Value.ShouldBe("ACTIVE"); + res[2].StatusCode.ShouldBe(Good); + res[2].Value.ShouldBe(123.4567d); + } + + /// An empty batch is empty — and, being empty, costs the Agent nothing. + [Fact] + public async Task Read_of_an_empty_batch_is_empty_and_costs_no_round_trip() + { + var (driver, client) = await InitializedDriverAsync(); + var before = client.CurrentCallCount; + + var res = await driver.ReadAsync([], TestContext.Current.CancellationToken); + + res.Count.ShouldBe(0); + client.CurrentCallCount.ShouldBe(before); + } + + /// + /// A batch of N references costs exactly ONE /current. The whole point of reading the + /// Agent's whole-device snapshot is that per-tag round-trips never happen. + /// + [Fact] + public async Task Read_of_many_refs_costs_exactly_one_current_call() + { + var (driver, client) = await InitializedDriverAsync(); + var before = client.CurrentCallCount; + + var res = await driver.ReadAsync( + ["dev1_pos", "dev1_execution", "dev1_partcount", "dev1_program", "does-not-exist", "dev1_pos"], + TestContext.Current.CancellationToken); + + res.Count.ShouldBe(6); + client.CurrentCallCount.ShouldBe(before + 1); + } + + // ---- status-code fidelity: the index's distinctions must survive the trip ---- + + /// A genuinely unknown id — neither authored nor ever observed. + [Fact] + public async Task Read_of_an_unknown_ref_reports_BadNodeIdUnknown() + { + var (driver, _) = await InitializedDriverAsync(); + + var res = await driver.ReadAsync(["nope"], TestContext.Current.CancellationToken); + + res[0].StatusCode.ShouldBe(BadNodeIdUnknown); + res[0].Value.ShouldBeNull(); + } + + /// + /// An authored id the Agent has never reported is BadWaitingForInitialData, NOT + /// BadNodeIdUnknown — collapsing the two would tell an operator their correctly + /// authored tag does not exist. + /// + [Fact] + public async Task Read_of_an_authored_but_unreported_ref_reports_BadWaitingForInitialData() + { + var (driver, _) = await InitializedDriverAsync(); + + var res = await driver.ReadAsync(["dev1_never_reported"], TestContext.Current.CancellationToken); + + res[0].StatusCode.ShouldBe(BadWaitingForInitialData); + } + + /// The Agent said UNAVAILABLE: reachable Agent, absent machine value. + [Fact] + public async Task Read_of_an_unavailable_ref_reports_BadNoCommunication() + { + var (driver, _) = await InitializedDriverAsync(); + + var res = await driver.ReadAsync(["dev1_partcount"], TestContext.Current.CancellationToken); + + res[0].StatusCode.ShouldBe(BadNoCommunication); + res[0].Value.ShouldBeNull(); + res[0].SourceTimestampUtc.ShouldNotBeNull(); + } + + /// A TIME_SERIES vector is real data this build cannot represent (P1.5 deferral). + [Fact] + public async Task Read_of_a_time_series_ref_reports_BadNotSupported() + { + var (driver, _) = await InitializedDriverAsync(); + + var res = await driver.ReadAsync(["dev1_vibration_ts"], TestContext.Current.CancellationToken); + + res[0].StatusCode.ShouldBe(BadNotSupported); + } + + /// A blank reference is data, not a programming error — it codes Bad, it does not throw. + [Fact] + public async Task Read_of_a_blank_ref_is_bad_not_a_throw() + { + var (driver, _) = await InitializedDriverAsync(); + + var res = await driver.ReadAsync(["", " ", "dev1_pos"], TestContext.Current.CancellationToken); + + res.Count.ShouldBe(3); + res[0].StatusCode.ShouldBe(BadNodeIdUnknown); + res[1].StatusCode.ShouldBe(BadNodeIdUnknown); + res[2].StatusCode.ShouldBe(Good); + } + + /// The authored type is honoured: the Agent's text becomes a CLR value of that type. + [Fact] + public async Task Read_coerces_the_agent_text_to_the_authored_type() + { + var (driver, _) = await InitializedDriverAsync(); + + var res = await driver.ReadAsync(["dev1_pos"], TestContext.Current.CancellationToken); + + res[0].StatusCode.ShouldBe(Good); + res[0].Value.ShouldBeOfType().ShouldBe(123.4567d); + res[0].SourceTimestampUtc.ShouldBe(new DateTime(2026, 7, 24, 12, 0, 0, 200, DateTimeKind.Utc)); + } + + // ---- freshness: a read is a read, not a replay of the initialize baseline ---- + + /// + /// The whole justification for spending a round-trip: an OPC UA Read must reflect the + /// device's current state, not whatever + /// happened to prime. A driver that served the primed index would pass every other test in + /// this file. + /// + [Fact] + public async Task Read_reflects_the_agent_state_at_read_time_not_the_initialize_baseline() + { + var (driver, client) = await InitializedDriverAsync(); + + // The Agent has moved on since the priming /current (sample.xml reports 124.0100). + client.Current = CannedAgentClient.Chunk("Fixtures/sample.xml"); + + var res = await driver.ReadAsync(["dev1_pos"], TestContext.Current.CancellationToken); + + res[0].StatusCode.ShouldBe(Good); + res[0].Value.ShouldBe(124.01d); + } + + /// + /// The anti-clobber pin. ReadAsync indexes its /current into a + /// per-call snapshot and never writes the driver's shared observation index — that index has + /// exactly one writer, the Task 11 /sample pump. If a read wrote it, a /current + /// document older than the pump's newest delta would silently roll a subscribed value + /// backwards and leave it there until the next change. + /// + [Fact] + public async Task Read_does_not_write_the_shared_observation_index() + { + var (driver, client) = await InitializedDriverAsync(); + var primed = driver.ObservationIndex.Get("dev1_pos"); + + client.Current = CannedAgentClient.Chunk("Fixtures/sample.xml"); + var res = await driver.ReadAsync(["dev1_pos"], TestContext.Current.CancellationToken); + + // The read saw the new value... + res[0].Value.ShouldBe(124.01d); + + // ...and the shared index still holds exactly what the pump/priming left there. + var after = driver.ObservationIndex.Get("dev1_pos"); + after.Value.ShouldBe(primed.Value); + after.Value.ShouldBe(123.4567d); + } + + // ---- failure posture: Bad snapshots, never exceptions ---- + + /// + /// An unreachable Agent fails the batch's values, not the batch. Every reference codes + /// BadCommunicationError and the call returns normally. + /// + [Fact] + public async Task Read_codes_every_ref_bad_when_the_agent_call_fails() + { + var (driver, client) = await InitializedDriverAsync(); + client.CurrentFailure = new HttpRequestException("connection refused"); + + var res = await driver.ReadAsync( + ["dev1_pos", "dev1_execution", "does-not-exist"], TestContext.Current.CancellationToken); + + res.Count.ShouldBe(3); + res.ShouldAllBe(r => r.StatusCode == BadCommunicationError); + res.ShouldAllBe(r => r.Value == null); + } + + /// An answer the driver cannot make sense of is a Bad batch, not a thrown one. + [Fact] + public async Task Read_codes_every_ref_bad_when_the_agent_answer_is_unusable() + { + var (driver, client) = await InitializedDriverAsync(); + client.CurrentFailure = new System.Xml.XmlException("unexpected end of document"); + + var res = await driver.ReadAsync(["dev1_pos"], TestContext.Current.CancellationToken); + + res[0].StatusCode.ShouldBe(BadCommunicationError); + } + + /// + /// A failed read degrades the driver rather than leaving it advertising Healthy, and a later + /// successful read restores it. LastSuccessfulRead survives the degradation — it is + /// the "when did this driver last hear from the Agent" diagnostic. + /// + [Fact] + public async Task Read_failure_degrades_the_driver_and_a_later_success_restores_it() + { + var (driver, client) = await InitializedDriverAsync(); + client.CurrentFailure = new HttpRequestException("connection refused"); + + await driver.ReadAsync(["dev1_pos"], TestContext.Current.CancellationToken); + + var degraded = driver.GetHealth(); + degraded.State.ShouldBe(DriverState.Degraded); + degraded.LastSuccessfulRead.ShouldNotBeNull(); + degraded.LastError.ShouldNotBeNullOrWhiteSpace(); + + client.CurrentFailure = null; + await driver.ReadAsync(["dev1_pos"], TestContext.Current.CancellationToken); + + driver.GetHealth().State.ShouldBe(DriverState.Healthy); + } + + /// + /// R2-01 — the /current runs under a bounded deadline. An Agent that accepts the + /// request and never answers surfaces as a Bad batch on the driver's own clock rather than + /// wedging the caller forever. + /// + [Fact] + public async Task Read_is_bounded_by_the_per_call_deadline() + { + var (driver, client) = await InitializedDriverAsync(Opts(requestTimeoutMs: 50)); + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + client.CurrentGate = gate; + + try + { + var res = await driver.ReadAsync(["dev1_pos"], TestContext.Current.CancellationToken); + + res.Count.ShouldBe(1); + res[0].StatusCode.ShouldBe(BadCommunicationError); + } + finally + { + gate.TrySetResult(); + } + } + + /// + /// The one exception that may cross the capability boundary. Note the contrast with + /// : same code path, one + /// catch filter apart. + /// + [Fact] + public async Task Read_propagates_genuine_caller_cancellation() + { + var (driver, _) = await InitializedDriverAsync(); + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + await Should.ThrowAsync( + () => driver.ReadAsync(["dev1_pos"], cts.Token)); + } + + // ---- reads outside the initialized window ---- + + /// + /// A read before InitializeAsync has no Agent to ask. It reports + /// BadNotConnected for every reference — it does not throw, it does not + /// NullReferenceException, and it does not invent a value. + /// + [Fact] + public async Task Read_before_initialize_is_bad_not_connected() + { + var (driver, client) = NewDriver(); + + var res = await driver.ReadAsync(["dev1_pos", "nope"], TestContext.Current.CancellationToken); + + res.Count.ShouldBe(2); + res.ShouldAllBe(r => r.StatusCode == BadNotConnected); + client.CurrentCallCount.ShouldBe(0); + + // An un-started driver is Unknown, not Degraded: nothing has failed yet. + driver.GetHealth().State.ShouldBe(DriverState.Unknown); + } + + /// + /// Same posture after ShutdownAsync, and shutting down must stay reported as + /// — a read attempted against a deliberately stopped + /// driver is not a degradation. + /// + [Fact] + public async Task Read_after_shutdown_is_bad_not_connected_and_leaves_health_alone() + { + var (driver, _) = await InitializedDriverAsync(); + await driver.ShutdownAsync(TestContext.Current.CancellationToken); + + var res = await driver.ReadAsync(["dev1_pos"], TestContext.Current.CancellationToken); + + res[0].StatusCode.ShouldBe(BadNotConnected); + driver.GetHealth().State.ShouldBe(DriverState.Unknown); + } + + /// + /// A null reference list is a caller bug, not device data — the one place this method + /// is allowed to be loud, matching 's documented + /// posture ("the only exceptions raised are ArgumentNullException for a null argument"). + /// + [Fact] + public async Task Read_of_a_null_ref_list_throws_ArgumentNullException() + { + var (driver, _) = await InitializedDriverAsync(); + + await Should.ThrowAsync( + () => driver.ReadAsync(null!, TestContext.Current.CancellationToken)); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectStreamsParseTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectStreamsParseTests.cs new file mode 100644 index 00000000..6df71fa6 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectStreamsParseTests.cs @@ -0,0 +1,1345 @@ +using System.Diagnostics; +using System.Globalization; +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using Microsoft.Extensions.Logging; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests; + +/// +/// Task 7 — the /current + /sample legs: +/// turning an MTConnectStreams document into , the +/// ring-buffer sequence-gap signal (), and the +/// multipart/x-mixed-replace chunk framing + heartbeat watchdog on the long-poll leg. +/// Every test runs against canned fixtures or a stubbed — no +/// socket is opened anywhere in this file. +/// +public sealed class MTConnectStreamsParseTests +{ + private const string CurrentFixture = "Fixtures/current.xml"; + private const string SampleFixture = "Fixtures/sample.xml"; + private const string GapFixture = "Fixtures/sample-gap.xml"; + private const string UnavailableFixture = "Fixtures/current-unavailable.xml"; + + /// The fixtures deliberately share one instanceId, so a gap — not a restart — is under test. + private const long FixtureInstanceId = 1655000000L; + + private static readonly string[] AllFixtureDataItemIds = + [ + "dev1_avail", + "dev1_pos", + "dev1_vibration_ts", + "dev1_partcount", + "dev1_execution", + "dev1_program", + "dev1_system_cond" + ]; + + // ------------------------------------------------------------------ header sequence parsing + + [Fact] + public void Current_parse_reads_header_sequences_and_observations() + { + var result = MTConnectStreamsParser.Parse(File.ReadAllText(CurrentFixture)); + + result.NextSequence.ShouldBeGreaterThan(0); + result.Observations.ShouldNotBeEmpty(); + } + + [Theory] + [InlineData(CurrentFixture, 1L, 108L)] + [InlineData(SampleFixture, 108L, 113L)] + [InlineData(GapFixture, 5000L, 5005L)] + [InlineData(UnavailableFixture, 200L, 207L)] + public void Parse_reads_first_and_next_sequence_and_the_instance_id_from_every_fixture( + string fixture, long firstSequence, long nextSequence) + { + var result = MTConnectStreamsParser.Parse(File.ReadAllText(fixture)); + + result.FirstSequence.ShouldBe(firstSequence); + result.NextSequence.ShouldBe(nextSequence); + result.InstanceId.ShouldBe(FixtureInstanceId); + } + + // ------------------------------------------------------------------------ observation values + + [Fact] + public void Current_parse_keys_every_observation_by_its_dataitem_id() + { + var result = MTConnectStreamsParser.Parse(File.ReadAllText(CurrentFixture)); + + result.Observations.Select(o => o.DataItemId).ShouldBe(AllFixtureDataItemIds, ignoreOrder: true); + } + + [Fact] + public void Current_parse_reads_the_good_value_the_agent_reported() + { + var observations = ParseCurrent(); + + // The observation ELEMENT is , not ; the value is its text content. + observations["dev1_pos"].Value.ShouldBe("123.4567"); + observations["dev1_execution"].Value.ShouldBe("ACTIVE"); + observations["dev1_program"].Value.ShouldBe("O1234"); + observations["dev1_avail"].Value.ShouldBe("AVAILABLE"); + } + + [Fact] + public void Current_parse_carries_the_unavailable_text_sentinel_through_verbatim() + { + // Task 8 maps UNAVAILABLE => BadNoCommunication; this layer must not pre-interpret it. + ParseCurrent()["dev1_partcount"].Value.ShouldBe("UNAVAILABLE"); + } + + [Fact] + public void Current_parse_keeps_a_time_series_vector_as_its_raw_string() + { + ParseCurrent()["dev1_vibration_ts"].Value.ShouldBe("1.1 1.2 1.3 1.4 1.5 1.6 1.7 1.8 1.9 2.0"); + } + + // ---------------------------------------------------------------------- CONDITION observations + + [Fact] + public void A_condition_observations_value_is_its_element_name_not_its_empty_text() + { + // — the element name IS the state, and the + // element is empty, so a text-content read yields "" and loses the observation entirely. + ParseCurrent()["dev1_system_cond"].Value.ShouldBe("Normal"); + } + + [Theory] + [InlineData("Normal", "Normal")] + [InlineData("Warning", "Warning")] + [InlineData("Fault", "Fault")] + public void A_condition_state_element_name_becomes_the_value(string element, string expected) + { + var result = MTConnectStreamsParser.Parse(StreamsDocument( + $"""<{element} dataItemId="c" timestamp="2026-07-24T12:00:00Z" sequence="1" type="SYSTEM"/>""")); + + result.Observations.ShouldHaveSingleItem().Value.ShouldBe(expected); + } + + [Fact] + public void A_condition_with_text_content_still_takes_its_value_from_the_element_name() + { + // Agents put the operator-facing condition message in the element text; the STATE is the name. + var result = MTConnectStreamsParser.Parse(StreamsDocument( + """Spindle overtemperature""")); + + result.Observations.ShouldHaveSingleItem().Value.ShouldBe("Fault"); + } + + [Fact] + public void An_unavailable_condition_element_is_the_unavailable_sentinel() + { + // is genuinely no-comms and must land on the exact same sentinel Task 8 + // matches for a Sample/Event's literal UNAVAILABLE text. + var observations = ParseFixture(UnavailableFixture); + + observations["dev1_system_cond"].Value.ShouldBe("UNAVAILABLE"); + } + + [Fact] + public void The_all_unavailable_fixture_is_all_unavailable() + { + var result = MTConnectStreamsParser.Parse(File.ReadAllText(UnavailableFixture)); + + result.Observations.Select(o => o.DataItemId).ShouldBe(AllFixtureDataItemIds, ignoreOrder: true); + result.Observations.ShouldAllBe(o => o.Value == "UNAVAILABLE"); + } + + // ------------------------------------------------------------------------------- timestamps + + [Fact] + public void Parse_normalizes_every_timestamp_to_utc() + { + // A wrong Kind silently shifts the OPC UA SourceTimestamp by the machine's UTC offset. + var result = MTConnectStreamsParser.Parse(File.ReadAllText(CurrentFixture)); + + result.Observations.ShouldAllBe(o => o.TimestampUtc.Kind == DateTimeKind.Utc); + ParseCurrent()["dev1_pos"].TimestampUtc + .ShouldBe(new DateTime(2026, 7, 24, 12, 0, 0, 200, DateTimeKind.Utc)); + } + + [Fact] + public void An_offset_timestamp_is_converted_to_utc_rather_than_taken_at_face_value() + { + var result = MTConnectStreamsParser.Parse(StreamsDocument( + """O1""")); + + var observation = result.Observations.ShouldHaveSingleItem(); + observation.TimestampUtc.Kind.ShouldBe(DateTimeKind.Utc); + observation.TimestampUtc.ShouldBe(new DateTime(2026, 7, 24, 12, 0, 0, DateTimeKind.Utc)); + } + + // -------------------------------------------------------------- sequence-gap detection (both ways) + + [Fact] + public void Sequence_gap_is_detected_when_firstSequence_exceeds_requested_from() + { + var chunk = MTConnectStreamsParser.Parse(File.ReadAllText(GapFixture)); + + IMTConnectAgentClient.IsSequenceGap(expectedFrom: 1, chunk).ShouldBeTrue(); + } + + [Fact] + public void No_sequence_gap_is_reported_for_a_contiguous_chunk() + { + // The falsifiability leg: a helper hard-wired to true would pass the gap test alone. + var contiguous = MTConnectStreamsParser.Parse(File.ReadAllText(SampleFixture)); + + IMTConnectAgentClient.IsSequenceGap(expectedFrom: 108, contiguous).ShouldBeFalse(); + } + + [Theory] + [InlineData(4999L, true)] // expected older than the buffer holds — data was lost + [InlineData(5000L, false)] // exactly the oldest retained sequence — nothing was lost + [InlineData(5001L, false)] // the agent simply had nothing older to send + public void Sequence_gap_is_strictly_firstSequence_greater_than_expected_from(long expectedFrom, bool expected) + { + var chunk = MTConnectStreamsParser.Parse(File.ReadAllText(GapFixture)); + + IMTConnectAgentClient.IsSequenceGap(expectedFrom, chunk).ShouldBe(expected); + } + + // --------------------------------------------------------------- legal but degenerate documents + + [Fact] + public void A_document_with_a_valid_header_and_no_observations_parses_rather_than_throwing() + { + // /sample chunks are deltas; an idle agent legitimately sends a document with nothing in it. + const string Xml = """ + +
+ + + """; + + var result = MTConnectStreamsParser.Parse(Xml); + + result.Observations.ShouldBeEmpty(); + result.InstanceId.ShouldBe(7); + result.NextSequence.ShouldBe(10); + result.FirstSequence.ShouldBe(10); + } + + [Fact] + public void A_component_stream_may_omit_any_of_samples_events_and_condition() + { + // sample.xml carries no at all; absence is normal, not an error. + var result = MTConnectStreamsParser.Parse(File.ReadAllText(SampleFixture)); + + result.Observations.Select(o => o.DataItemId).ShouldBe( + ["dev1_pos", "dev1_vibration_ts", "dev1_partcount", "dev1_execution", "dev1_program"], ignoreOrder: true); + } + + [Fact] + public void Parse_collects_observations_across_every_device_and_component_stream() + { + const string Xml = """ + +
+ + + + AVAILABLE + + + + + 1.0 + + + + + + """; + + var result = MTConnectStreamsParser.Parse(Xml); + + result.Observations.Select(o => o.DataItemId).ShouldBe(["a_avail", "b_pos", "b_cond"]); + result.Observations[2].Value.ShouldBe("Warning"); + } + + [Theory] + [InlineData("urn:mtconnect.org:MTConnectStreams:1.3")] + [InlineData("urn:mtconnect.org:MTConnectStreams:2.0")] + [InlineData("")] + public void Parse_is_agnostic_to_the_document_namespace_version(string namespaceUri) + { + var xmlns = namespaceUri.Length == 0 ? string.Empty : $" xmlns=\"{namespaceUri}\""; + var xml = $""" + +
+ + O1 + + + """; + + MTConnectStreamsParser.Parse(xml).Observations.ShouldHaveSingleItem().DataItemId.ShouldBe("p"); + } + + [Fact] + public void Parse_from_a_stream_matches_the_string_overload() + { + using var stream = File.OpenRead(CurrentFixture); + + var fromStream = MTConnectStreamsParser.Parse(stream); + + var fromString = MTConnectStreamsParser.Parse(File.ReadAllText(CurrentFixture)); + fromStream.NextSequence.ShouldBe(fromString.NextSequence); + fromStream.Observations.ShouldBe(fromString.Observations); + } + + // ------------------------------------------------------------- fails loudly, never silently empty + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("not xml at all")] + [InlineData("")] + public void Parse_throws_on_malformed_xml(string xml) + { + Should.Throw(() => MTConnectStreamsParser.Parse(xml)); + } + + [Fact] + public void Parse_throws_on_a_devices_document_served_where_streams_were_expected() + { + var ex = Should.Throw( + () => MTConnectStreamsParser.Parse("")); + + ex.Message.ShouldContain("MTConnectStreams"); + ex.Message.ShouldContain("MTConnectDevices"); + } + + [Fact] + public void Parse_throws_on_an_mtconnect_error_document_and_surfaces_the_agent_error() + { + // An agent answers an out-of-range `from` with an MTConnectError document under HTTP 200. + const string Xml = """ + +
+ + 'from' must be greater than 100 + + + """; + + var ex = Should.Throw(() => MTConnectStreamsParser.Parse(Xml)); + + ex.Message.ShouldContain("OUT_OF_RANGE"); + ex.Message.ShouldContain("'from' must be greater than 100"); + } + + [Theory] + [InlineData("")] // no
at all + [InlineData("""
""")] // no nextSequence + [InlineData("""
""")] // no firstSequence + [InlineData("""
""")] // no instanceId + [InlineData("""
""")] // non-numeric + [InlineData("""
""")] // non-numeric + public void Parse_throws_on_an_unusable_header(string headerXml) + { + var xml = $"{headerXml}"; + + Should.Throw(() => MTConnectStreamsParser.Parse(xml)); + } + + [Theory] + [InlineData("""1""")] // no dataItemId + [InlineData("""1""")] // no timestamp + [InlineData("""1""")] // unparseable timestamp + public void Parse_throws_on_a_malformed_observation(string observationXml) + { + Should.Throw( + () => MTConnectStreamsParser.Parse(StreamsDocument($"{observationXml}"))); + } + + [Fact] + public void Parse_reads_only_unqualified_attributes() + { + // A prefixed x:dataItemId is a vendor extension, not the observation's identity — reading it + // would invent a data item that the probe never declared. + var xml = StreamsDocument( + """1"""); + + Should.Throw(() => MTConnectStreamsParser.Parse(xml)); + } + + // ------------------------------------------------------------- MTConnectAgentClient.CurrentAsync + + [Fact] + public async Task CurrentAsync_requests_the_agent_current_path_and_returns_the_parsed_snapshot() + { + var handler = StubHandler.RespondingWith(await File.ReadAllTextAsync(CurrentFixture, Ct)); + using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); + + var result = await client.CurrentAsync(Ct); + + handler.LastRequestUri!.AbsoluteUri.ShouldBe("http://agent:5000/current"); + result.NextSequence.ShouldBe(108); + result.Observations.Count.ShouldBe(7); + } + + [Fact] + public async Task CurrentAsync_scopes_the_request_to_the_configured_device_name() + { + var handler = StubHandler.RespondingWith(await File.ReadAllTextAsync(CurrentFixture, Ct)); + using var client = NewClient( + handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000/", DeviceName = "VMC 3Axis" }); + + await client.CurrentAsync(Ct); + + handler.LastRequestUri!.AbsoluteUri.ShouldBe("http://agent:5000/VMC%203Axis/current"); + } + + [Fact] + public async Task CurrentAsync_bounds_a_hung_agent_by_the_configured_request_timeout() + { + using var client = NewClient( + StubHandler.Hanging(), + new MTConnectDriverOptions { AgentUri = "http://agent:5000", RequestTimeoutMs = 50 }); + + var started = Stopwatch.StartNew(); + var ex = await Should.ThrowAsync(async () => await client.CurrentAsync(Ct)); + + ex.ShouldNotBeAssignableTo(); + ex.Message.ShouldContain("http://agent:5000/current"); + started.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(10)); + } + + [Fact] + public async Task CurrentAsync_throws_on_an_error_document_served_under_http_200() + { + var handler = StubHandler.RespondingWith( + """ + nope + """); + using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); + + var ex = await Should.ThrowAsync(async () => await client.CurrentAsync(Ct)); + + ex.Message.ShouldContain("NO_DEVICE"); + } + + [Fact] + public async Task CurrentAsync_throws_on_a_non_success_status() + { + var handler = StubHandler.RespondingWith("nope", HttpStatusCode.ServiceUnavailable); + using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); + + await Should.ThrowAsync(async () => await client.CurrentAsync(Ct)); + } + + // -------------------------------------------------------------- MTConnectAgentClient.SampleAsync + + [Fact] + public async Task SampleAsync_composes_the_from_interval_count_and_heartbeat_query() + { + var handler = StubHandler.RespondingWithMultipart("bnd", await File.ReadAllTextAsync(SampleFixture, Ct)); + using var client = NewClient(handler, new MTConnectDriverOptions + { + AgentUri = "http://agent:5000", + DeviceName = "VMC-3Axis", + SampleIntervalMs = 250, + SampleCount = 64, + HeartbeatMs = 7000 + }); + + await DrainAsync(client.SampleAsync(108, Ct)); + + handler.LastRequestUri!.AbsoluteUri.ShouldBe( + "http://agent:5000/VMC-3Axis/sample?from=108&interval=250&count=64&heartbeat=7000"); + } + + [Fact] + public async Task SampleAsync_yields_one_result_per_multipart_chunk() + { + var handler = StubHandler.RespondingWithMultipart( + "--------bnd", + await File.ReadAllTextAsync(SampleFixture, Ct), + await File.ReadAllTextAsync(GapFixture, Ct)); + using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); + + var chunks = await DrainAsync(client.SampleAsync(108, Ct)); + + chunks.Count.ShouldBe(2); + chunks[0].FirstSequence.ShouldBe(108); + chunks[0].NextSequence.ShouldBe(113); + chunks[1].FirstSequence.ShouldBe(5000); + chunks[1].NextSequence.ShouldBe(5005); + } + + [Fact] + public async Task SampleAsync_frames_chunks_from_the_boundary_when_the_agent_omits_content_length() + { + var handler = StubHandler.RespondingWithMultipart( + "bnd", + withContentLength: false, + await File.ReadAllTextAsync(SampleFixture, Ct), + await File.ReadAllTextAsync(GapFixture, Ct)); + using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); + + var chunks = await DrainAsync(client.SampleAsync(108, Ct)); + + chunks.Select(c => c.FirstSequence).ShouldBe([108L, 5000L]); + } + + [Fact] + public async Task SampleAsync_exposes_the_gap_signal_to_the_caller_on_the_chunk_that_carries_it() + { + // Task 11's pump: advance `from` to NextSequence while contiguous, re-baseline on a gap. + var handler = StubHandler.RespondingWithMultipart( + "bnd", + await File.ReadAllTextAsync(SampleFixture, Ct), + await File.ReadAllTextAsync(GapFixture, Ct)); + using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); + + var from = 108L; + var gaps = new List(); + await Should.ThrowAsync(async () => + { + await foreach (var chunk in client.SampleAsync(from, Ct)) + { + // `from` is the RUNNING cursor, not the original argument — comparing every chunk + // against the opening `from` would report a gap on every chunk once the Agent's + // buffer rolls past it (I1). + gaps.Add(IMTConnectAgentClient.IsSequenceGap(from, chunk)); + from = chunk.NextSequence; + } + }); + + gaps.ShouldBe([false, true]); + from.ShouldBe(5005); + } + + [Fact] + public async Task SampleAsync_ignores_an_empty_keepalive_part() + { + var handler = StubHandler.RespondingWithMultipart( + "bnd", + await File.ReadAllTextAsync(SampleFixture, Ct), + "", + await File.ReadAllTextAsync(GapFixture, Ct)); + using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); + + var chunks = await DrainAsync(client.SampleAsync(108, Ct)); + + chunks.Count.ShouldBe(2); + } + + [Fact] + public async Task SampleAsync_yields_a_heartbeat_document_that_carries_no_observations() + { + // The agent's real keep-alive is a well-formed but observation-free MTConnectStreams + // document — it advances the sequence, so it must reach the caller, not be swallowed. + const string Heartbeat = """ + +
+ + + """; + var handler = StubHandler.RespondingWithMultipart("bnd", Heartbeat); + using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); + + var chunks = await DrainAsync(client.SampleAsync(113, Ct)); + + chunks.ShouldHaveSingleItem().Observations.ShouldBeEmpty(); + IMTConnectAgentClient.IsSequenceGap(113, chunks[0]).ShouldBeFalse(); + } + + [Fact(Timeout = 30_000)] + public async Task SampleAsync_faults_on_the_heartbeat_watchdog_rather_than_hanging_on_a_silent_agent() + { + // The R2-01 frozen-peer shape: the agent accepts the connection, sends one chunk, then goes + // silent forever. Without a watchdog the pump wedges with no timeout ever firing. + var prefix = StubHandler.MultipartBody("bnd", withContentLength: true, closing: false, + await File.ReadAllTextAsync(SampleFixture, Ct)); + var handler = StubHandler.RespondingWithStallingStream("bnd", prefix); + using var client = NewClient(handler, new MTConnectDriverOptions + { + AgentUri = "http://agent:5000", + RequestTimeoutMs = 100, + HeartbeatMs = 100 + }); + + var started = Stopwatch.StartNew(); + await using var chunks = client.SampleAsync(108, Ct).GetAsyncEnumerator(Ct); + + (await chunks.MoveNextAsync()).ShouldBeTrue(); + chunks.Current.FirstSequence.ShouldBe(108); + + var ex = await Should.ThrowAsync(async () => await chunks.MoveNextAsync()); + ex.ShouldNotBeAssignableTo(); + ex.Message.ShouldContain("sample"); + started.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(20)); + } + + [Fact(Timeout = 30_000)] + public async Task SampleAsync_bounds_the_response_header_phase_by_the_request_timeout() + { + // A peer that accepts the TCP connection but never answers must not wedge the pump either. + using var client = NewClient( + StubHandler.Hanging(), + new MTConnectDriverOptions { AgentUri = "http://agent:5000", RequestTimeoutMs = 50 }); + + var ex = await Should.ThrowAsync(async () => await DrainAsync(client.SampleAsync(1, Ct))); + + ex.ShouldNotBeAssignableTo(); + ex.Message.ShouldContain("/sample"); + } + + [Fact] + public async Task SampleAsync_throws_on_an_error_document_served_under_http_200() + { + // Not a multipart body at all — the agent answers a bad `from` with an error document. + var handler = StubHandler.RespondingWith( + """ + bad from + """); + using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); + + var ex = await Should.ThrowAsync( + async () => await DrainAsync(client.SampleAsync(1, Ct))); + + ex.Message.ShouldContain("OUT_OF_RANGE"); + } + + [Fact] + public async Task SampleAsync_throws_on_a_non_success_status() + { + var handler = StubHandler.RespondingWith("nope", HttpStatusCode.BadRequest); + using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); + + await Should.ThrowAsync(async () => await DrainAsync(client.SampleAsync(1, Ct))); + } + + [Fact(Timeout = 30_000)] + public async Task SampleAsync_honours_the_callers_cancellation_token() + { + var handler = StubHandler.RespondingWithStallingStream("bnd", []); + using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); + using var cts = new CancellationTokenSource(); + await cts.CancelAsync(); + + await Should.ThrowAsync( + async () => await DrainAsync(client.SampleAsync(1, cts.Token))); + } + + [Fact] + public async Task SampleAsync_reports_the_closing_boundary_rather_than_ending_quietly() + { + // C1. The seam contracts SampleAsync to run until cancelled, so a pump has no reason to + // handle normal completion: returning here would either drop the subscription silently or + // hot-loop reconnecting on an enumerator that ends immediately (#485). + var handler = StubHandler.RespondingWithMultipart("bnd", await File.ReadAllTextAsync(SampleFixture, Ct)); + using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); + + var chunks = new List(); + var ex = await Should.ThrowAsync(async () => + { + await foreach (var chunk in client.SampleAsync(108, Ct)) + { + chunks.Add(chunk); + } + }); + + chunks.ShouldHaveSingleItem(); + ex.Reason.ShouldBe(MTConnectStreamEndReason.ClosingBoundary); + ex.ChunksDelivered.ShouldBe(1); + ex.AgentUri.ShouldContain("/sample?from=108"); + } + + [Fact] + public async Task SampleAsync_reports_a_connection_that_drops_mid_stream() + { + // No closing boundary — the body simply stops. Distinguished from a deliberate close so an + // operator can tell a flaky network from an Agent honouring a bounded request. + var truncated = StubHandler.MultipartBody( + "bnd", withContentLength: true, closing: false, await File.ReadAllTextAsync(SampleFixture, Ct)); + var handler = StubHandler.RespondingWithBody("bnd", truncated); + using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); + + var ex = await Should.ThrowAsync( + async () => await EnumerateAsync(client.SampleAsync(108, Ct))); + + ex.Reason.ShouldBe(MTConnectStreamEndReason.ConnectionClosed); + ex.ChunksDelivered.ShouldBe(1); + } + + [Fact] + public async Task SampleAsync_reports_a_non_multipart_answer_as_a_configuration_error() + { + // The worst C1 shape: the Agent ignored the streaming request entirely. Yielding the one + // snapshot it did return and finishing would report a dead subscription as a healthy one. + var handler = StubHandler.RespondingWith(await File.ReadAllTextAsync(CurrentFixture, Ct)); + using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); + + var ex = await Should.ThrowAsync( + async () => await EnumerateAsync(client.SampleAsync(108, Ct))); + + ex.Message.ShouldContain("multipart/x-mixed-replace"); + ex.Message.ShouldContain("text/xml"); + } + + [Fact] + public async Task SampleAsync_yields_nothing_at_all_from_a_non_multipart_answer() + { + // Explicitly pinned: the parsed document must NOT reach the caller. One snapshot plus a + // finished stream is precisely how a dead data path disguises itself as a working one. + var handler = StubHandler.RespondingWith(await File.ReadAllTextAsync(CurrentFixture, Ct)); + using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); + + var chunks = new List(); + await Should.ThrowAsync(async () => + { + await foreach (var chunk in client.SampleAsync(108, Ct)) + { + chunks.Add(chunk); + } + }); + + chunks.ShouldBeEmpty(); + } + + [Fact] + public async Task SampleAsync_fails_fast_when_a_multipart_response_carries_no_boundary_parameter() + { + // M2. Falling through to whole-body reading would surface only as a watchdog TimeoutException + // much later, pointing an operator at the network instead of at the malformed header. + var handler = StubHandler.RespondingWithBoundarylessMultipart(); + using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); + + var ex = await Should.ThrowAsync( + async () => await EnumerateAsync(client.SampleAsync(1, Ct))); + + ex.Message.ShouldContain("boundary"); + } + + [Fact] + public async Task A_stream_end_and_a_stream_misconfiguration_share_one_catchable_base_type() + { + // A pump that only wants "the stream is over" should not have to name both. + var handler = StubHandler.RespondingWithMultipart("bnd", await File.ReadAllTextAsync(SampleFixture, Ct)); + using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); + + var ended = await Should.ThrowAsync( + async () => await EnumerateAsync(client.SampleAsync(108, Ct))); + + ended.ShouldBeAssignableTo(); + typeof(MTConnectStreamException).IsAssignableFrom(typeof(MTConnectStreamNotSupportedException)).ShouldBeTrue(); + } + + [Fact] + public void SampleAsync_opens_no_connection_until_it_is_enumerated() + { + var handler = StubHandler.Hanging(); + using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); + + _ = client.SampleAsync(1, CancellationToken.None); + + handler.RequestCount.ShouldBe(0); + } + + // ------------------------------------------- I2: framing when the transport splits every read + + /// + /// Serving the whole body from one buffer completes every frame in a single + /// ReadAsync, so the split-boundary path, the split-header path and the multi-fill + /// loop are never entered — on the one behaviour whose headline risk is framing. These drive + /// the same fixtures through a transport that hands back only N bytes at a time; at N = 1 + /// every delimiter, every header line and every document is split maximally. + /// + [Theory] + [InlineData(1)] + [InlineData(3)] + [InlineData(7)] + public async Task SampleAsync_frames_correctly_when_the_transport_splits_every_boundary_and_header(int bytesPerRead) + { + var handler = StubHandler.RespondingWithChunkedMultipart( + "--------bnd", + bytesPerRead, + withContentLength: true, + await File.ReadAllTextAsync(SampleFixture, Ct), + await File.ReadAllTextAsync(GapFixture, Ct)); + using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); + + var chunks = await DrainAsync(client.SampleAsync(108, Ct)); + + chunks.Select(c => c.FirstSequence).ShouldBe([108L, 5000L]); + chunks.Select(c => c.NextSequence).ShouldBe([113L, 5005L]); + chunks[0].Observations.Count.ShouldBe(5); + } + + [Theory] + [InlineData(1)] + [InlineData(3)] + [InlineData(7)] + public async Task SampleAsync_boundary_scan_framing_survives_a_split_transport_too(int bytesPerRead) + { + var handler = StubHandler.RespondingWithChunkedMultipart( + "bnd", + bytesPerRead, + withContentLength: false, + await File.ReadAllTextAsync(SampleFixture, Ct), + await File.ReadAllTextAsync(GapFixture, Ct)); + using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); + + var chunks = await DrainAsync(client.SampleAsync(108, Ct)); + + chunks.Select(c => c.FirstSequence).ShouldBe([108L, 5000L]); + } + + [Fact] + public async Task SampleAsync_grows_its_buffer_for_a_part_larger_than_the_initial_read_buffer() + { + // Forces Array.Resize + a Consume across the grown buffer, which a 2 KB fixture never does. + var large = LargeStreamsDocument(observationCount: 900); + Encoding.UTF8.GetByteCount(large).ShouldBeGreaterThan(16 * 1024); + + var handler = StubHandler.RespondingWithChunkedMultipart( + "bnd", bytesPerRead: 1024, withContentLength: true, large, large); + using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); + + var chunks = await DrainAsync(client.SampleAsync(1, Ct)); + + chunks.Count.ShouldBe(2); + chunks[0].Observations.Count.ShouldBe(900); + chunks[1].Observations.Count.ShouldBe(900); + } + + // ------------------------------------------------------- I3: disposal during a live enumeration + + [Fact(Timeout = 30_000)] + public async Task Disposing_the_client_mid_enumeration_surfaces_as_cancellation() + { + // Task 9 re-initializes by disposing and rebuilding this client, which can happen while a + // pump is still enumerating. An unclassified ObjectDisposedException/IOException from the + // socket is something no caller is written to expect. + var prefix = StubHandler.MultipartBody( + "bnd", withContentLength: true, closing: false, await File.ReadAllTextAsync(SampleFixture, Ct)); + var handler = StubHandler.RespondingWithStallingStream("bnd", prefix); + var client = NewClient(handler, new MTConnectDriverOptions + { + AgentUri = "http://agent:5000", + RequestTimeoutMs = 5000, + HeartbeatMs = 20_000 + }); + + await using var chunks = client.SampleAsync(108, Ct).GetAsyncEnumerator(Ct); + (await chunks.MoveNextAsync()).ShouldBeTrue(); + + var pending = chunks.MoveNextAsync().AsTask(); + client.Dispose(); + + var ex = await Should.ThrowAsync(async () => await pending); + ex.ShouldNotBeOfType(); + } + + [Fact] + public async Task Using_a_disposed_client_throws_object_disposed_rather_than_dialling() + { + var handler = StubHandler.Hanging(); + var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); + client.Dispose(); + + await Should.ThrowAsync(async () => await client.CurrentAsync(Ct)); + await Should.ThrowAsync( + async () => await EnumerateAsync(client.SampleAsync(1, Ct))); + handler.RequestCount.ShouldBe(0); + } + + [Fact] + public void Dispose_is_idempotent() + { + var client = NewClient(StubHandler.Hanging(), new MTConnectDriverOptions { AgentUri = "http://agent:5000" }); + + client.Dispose(); + + Should.NotThrow(client.Dispose); + } + + // ------------------------------------------- I4: the degraded framing mode is operator-visible + + [Fact] + public async Task A_part_without_content_length_warns_once_that_framing_is_degraded() + { + var logger = new RecordingLogger(); + var handler = StubHandler.RespondingWithMultipart( + "bnd", + withContentLength: false, + await File.ReadAllTextAsync(SampleFixture, Ct), + await File.ReadAllTextAsync(GapFixture, Ct)); + using var client = new MTConnectAgentClient( + new MTConnectDriverOptions { AgentUri = "http://agent:5000" }, handler, logger); + + await DrainAsync(client.SampleAsync(108, Ct)); + + // Once, not once per part — a per-chunk warning on a long poll is its own outage. + logger.Warnings.Count.ShouldBe(1); + logger.Warnings[0].ShouldContain("Content-length"); + logger.Warnings[0].ShouldContain("http://agent:5000/sample"); + } + + [Fact] + public async Task The_normal_content_length_path_warns_about_nothing() + { + var logger = new RecordingLogger(); + var handler = StubHandler.RespondingWithMultipart("bnd", await File.ReadAllTextAsync(SampleFixture, Ct)); + using var client = new MTConnectAgentClient( + new MTConnectDriverOptions { AgentUri = "http://agent:5000" }, handler, logger); + + await DrainAsync(client.SampleAsync(108, Ct)); + + logger.Warnings.ShouldBeEmpty(); + } + + // ---------------------------------------- I5: operator-authorable options that reach the wire + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Ctor_rejects_a_non_positive_heartbeat(int heartbeatMs) + { + // heartbeat=0 goes on the query string, and an Agent answers it with an MTConnectError + // under HTTP 200 — surfacing as a parse failure that never names the offending key. + var ex = Should.Throw(() => new MTConnectAgentClient( + new MTConnectDriverOptions { AgentUri = "http://agent:5000", HeartbeatMs = heartbeatMs })); + + ex.Message.ShouldContain(nameof(MTConnectDriverOptions.HeartbeatMs)); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Ctor_rejects_a_non_positive_sample_interval(int sampleIntervalMs) + { + var ex = Should.Throw(() => new MTConnectAgentClient( + new MTConnectDriverOptions { AgentUri = "http://agent:5000", SampleIntervalMs = sampleIntervalMs })); + + ex.Message.ShouldContain(nameof(MTConnectDriverOptions.SampleIntervalMs)); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Ctor_rejects_a_non_positive_sample_count(int sampleCount) + { + var ex = Should.Throw(() => new MTConnectAgentClient( + new MTConnectDriverOptions { AgentUri = "http://agent:5000", SampleCount = sampleCount })); + + ex.Message.ShouldContain(nameof(MTConnectDriverOptions.SampleCount)); + } + + // ------------------------------------------- structured (DATA_SET / TABLE) observation signal + + [Fact] + public void An_observation_whose_content_is_child_entries_is_flagged_structured() + { + // children concatenate to "12" through element.Value — keys gone, values run + // together. The index codes this BadNotSupported rather than publishing noise as Good. + var result = MTConnectStreamsParser.Parse(StreamsDocument( + """ + + 12 + + """)); + + var observation = result.Observations.ShouldHaveSingleItem(); + observation.IsStructured.ShouldBeTrue(); + observation.DataItemId.ShouldBe("ds"); + } + + [Fact] + public void A_plain_text_observation_containing_spaces_is_NOT_flagged_structured() + { + // The load-bearing case: this proves the signal is "the element had element children", not + // a whitespace heuristic. A Message reading "Coolant level low" is perfectly valid data and + // is textually indistinguishable from concatenated entries. + var result = MTConnectStreamsParser.Parse(StreamsDocument( + """Coolant level low""")); + + var observation = result.Observations.ShouldHaveSingleItem(); + observation.IsStructured.ShouldBeFalse(); + observation.Value.ShouldBe("Coolant level low"); + } + + [Fact] + public void Every_observation_in_the_committed_fixtures_is_unstructured() + { + // Includes the space-separated TIME_SERIES vector, which is text and must stay unflagged. + var current = MTConnectStreamsParser.Parse(File.ReadAllText(CurrentFixture)); + + current.Observations.ShouldAllBe(o => !o.IsStructured); + } + + [Fact] + public void A_condition_is_never_flagged_structured_even_with_child_content() + { + // A condition's value comes from the element NAME, so child content cannot corrupt it. + // Flagging one would throw away a perfectly well-determined state. + var result = MTConnectStreamsParser.Parse(StreamsDocument( + """ + + vendor extension + + """)); + + var observation = result.Observations.ShouldHaveSingleItem(); + observation.IsStructured.ShouldBeFalse(); + observation.Value.ShouldBe("Fault"); + } + + // ------------------------------------------------------------------------------------ helpers + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private static MTConnectAgentClient NewClient(HttpMessageHandler handler, MTConnectDriverOptions options) => + new(options, handler); + + private static async Task> DrainAsync(IAsyncEnumerable source) + { + var results = new List(); + try + { + await foreach (var item in source) + { + results.Add(item); + } + } + catch (MTConnectStreamEndedException) + { + // Expected under the C1 contract: every non-cancellation end of a /sample stream is + // loud. Tests that care about HOW it ended assert on the exception directly. + } + + return results; + } + + /// Enumerates to completion, discarding chunks and letting every exception escape. + private static async Task EnumerateAsync(IAsyncEnumerable source) + { + await foreach (var _ in source) + { + // Drained for effect only. + } + } + + /// A streams document big enough to force the frame reader to grow its buffer. + private static string LargeStreamsDocument(int observationCount) + { + var samples = new StringBuilder(); + for (var i = 0; i < observationCount; i++) + { + samples.Append(CultureInfo.InvariantCulture, + $"""{i}.5"""); + } + + return $""" + +
+ + {samples} + + + """; + } + + private static Dictionary ParseCurrent() => ParseFixture(CurrentFixture); + + private static Dictionary ParseFixture(string path) => + MTConnectStreamsParser.Parse(File.ReadAllText(path)).Observations.ToDictionary(o => o.DataItemId); + + /// Wraps observation-container XML in a minimal but valid streams document. + private static string StreamsDocument(string componentStreamBody) => + $""" + +
+ + + {componentStreamBody} + + + + """; + + /// A canned — no socket is ever opened. + private sealed class StubHandler : HttpMessageHandler + { + private readonly Func? _responder; + private readonly bool _hang; + + private StubHandler(Func? responder, bool hang) + { + _responder = responder; + _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(() => new HttpResponseMessage(status) + { + Content = new StringContent(body, Encoding.UTF8, "text/xml") + }, hang: false); + + public static StubHandler Hanging() => new(responder: null, hang: true); + + public static StubHandler RespondingWithMultipart(string boundary, params string[] parts) => + RespondingWithMultipart(boundary, withContentLength: true, parts); + + public static StubHandler RespondingWithMultipart(string boundary, bool withContentLength, params string[] parts) + { + var body = MultipartBody(boundary, withContentLength, closing: true, parts); + + return new StubHandler(() => Multipart(boundary, new ByteArrayContent(body)), hang: false); + } + + public static StubHandler RespondingWithStallingStream(string boundary, byte[] prefix) => + new(() => Multipart(boundary, new StallingContent(prefix)), hang: false); + + /// Serves an already-built multipart body verbatim (e.g. one with no closing delimiter). + public static StubHandler RespondingWithBody(string boundary, byte[] body) => + new(() => Multipart(boundary, new ByteArrayContent(body)), hang: false); + + /// + /// Serves a multipart body through a transport that hands back at most + /// bytes per ReadAsync, so boundaries, part + /// headers and documents are all split across reads. + /// + public static StubHandler RespondingWithChunkedMultipart( + string boundary, int bytesPerRead, bool withContentLength, params string[] parts) + { + var body = MultipartBody(boundary, withContentLength, closing: true, parts); + + return new StubHandler(() => Multipart(boundary, new ChunkedContent(body, bytesPerRead)), hang: false); + } + + /// A response that claims multipart but omits the boundary parameter. + public static StubHandler RespondingWithBoundarylessMultipart() => + new( + () => + { + var content = new ByteArrayContent([]); + content.Headers.ContentType = new MediaTypeHeaderValue("multipart/x-mixed-replace"); + + return new HttpResponseMessage(HttpStatusCode.OK) { Content = content }; + }, + hang: false); + + /// Builds a multipart/x-mixed-replace body exactly as an Agent writes one. + public static byte[] MultipartBody(string boundary, bool withContentLength, bool closing, params string[] parts) + { + var body = new List(); + foreach (var part in parts) + { + var payload = Encoding.UTF8.GetBytes(part); + var header = new StringBuilder() + .Append("--").Append(boundary).Append("\r\n") + .Append("Content-type: text/xml\r\n"); + if (withContentLength) + { + header.Append("Content-length: ").Append(payload.Length).Append("\r\n"); + } + + header.Append("\r\n"); + body.AddRange(Encoding.ASCII.GetBytes(header.ToString())); + body.AddRange(payload); + body.AddRange("\r\n"u8.ToArray()); + } + + if (closing) + { + body.AddRange(Encoding.ASCII.GetBytes($"--{boundary}--\r\n")); + } + + return [.. body]; + } + + private static HttpResponseMessage Multipart(string boundary, HttpContent content) + { + content.Headers.ContentType = + MediaTypeHeaderValue.Parse($"multipart/x-mixed-replace; boundary={boundary}"); + + return new HttpResponseMessage(HttpStatusCode.OK) { Content = content }; + } + + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + LastRequestUri = request.RequestUri; + RequestCount++; + + if (_hang) + { + await Task.Delay(Timeout.Infinite, cancellationToken); + } + + return _responder!(); + } + } + + /// Hands back at most bytesPerRead bytes per read — the split-transport double. + private sealed class ChunkedContent(byte[] body, int bytesPerRead) : HttpContent + { + protected override Task CreateContentReadStreamAsync() => + Task.FromResult(new ChunkedStream(body, bytesPerRead)); + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => + stream.WriteAsync(body, 0, body.Length); + + protected override bool TryComputeLength(out long length) + { + length = body.Length; + + return true; + } + } + + private sealed class ChunkedStream(byte[] body, int bytesPerRead) : Stream + { + private int _position; + + public override bool CanRead => true; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Length => body.Length; + + public override long Position + { + get => _position; + set => throw new NotSupportedException(); + } + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (_position >= body.Length) + { + return ValueTask.FromResult(0); + } + + var count = Math.Min(Math.Min(bytesPerRead, buffer.Length), body.Length - _position); + body.AsMemory(_position, count).CopyTo(buffer); + _position += count; + + return ValueTask.FromResult(count); + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => + ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } + + /// Captures warning-level log lines so the degraded-framing notice can be asserted. + private sealed class RecordingLogger : ILogger + { + public List Warnings { get; } = []; + + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + if (logLevel == LogLevel.Warning) + { + Warnings.Add(formatter(state, exception)); + } + } + } + + /// Serves , then never completes another read. + private sealed class StallingContent(byte[] prefix) : HttpContent + { + protected override Task CreateContentReadStreamAsync() => + Task.FromResult(new StallingStream(prefix)); + + protected override Task SerializeToStreamAsync(Stream stream, TransportContext? context) => + Task.CompletedTask; + + protected override bool TryComputeLength(out long length) + { + length = -1; + + return false; + } + } + + private sealed class StallingStream(byte[] prefix) : Stream + { + private int _position; + + public override bool CanRead => true; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + if (_position < prefix.Length) + { + var count = Math.Min(buffer.Length, prefix.Length - _position); + prefix.AsMemory(_position, count).CopyTo(buffer); + _position += count; + + return count; + } + + await Task.Delay(Timeout.Infinite, cancellationToken); + + return 0; + } + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) => + ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask(); + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + + public override void SetLength(long value) => throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectSubscribeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectSubscribeTests.cs new file mode 100644 index 00000000..b12c32ff --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectSubscribeTests.cs @@ -0,0 +1,1092 @@ +using System.Collections.Concurrent; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests; + +/// +/// Task 11 — 's half: one shared +/// /sample long-poll pump behind every handle, the ring-buffer re-baseline, and the +/// teardown that must leave nothing running. Every test runs against +/// ; no socket is opened anywhere in this file. +/// +/// +/// +/// Nothing here waits on wall-clock time. The fake completes a +/// only once the pump has finished with that +/// chunk, and exposes the pump's own task +/// () as the teardown barrier — so every +/// "the pump has now done X" statement is a fact, not a sleep. The one +/// below is a failure guard that turns a genuine hang (an +/// un-terminated retry loop, a deadlocked teardown) into a clean red test; no assertion +/// is ever made about elapsed time. +/// +/// +/// The load-bearing tests are the ones about a stream that has gone wrong. A pump +/// that only ever meets contiguous chunks is a dozen lines; the defects live in the four +/// ways it can be knocked off the sequence — the buffer rolling past the cursor +/// (IsSequenceGap), the Agent refusing an evicted from outright +/// (OUT_OF_RANGE under HTTP 200 ⇒ ), the Agent +/// restarting (a new instanceId, which ALSO looks like a gap), and the connection +/// dropping — plus the one failure it must NOT retry +/// (, where reconnecting reproduces the +/// identical answer forever). +/// +/// +public sealed class MTConnectSubscribeTests +{ + 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; + + /// The cursor Fixtures/current.xml primes the pump with. + private const long PrimedNextSequence = 108L; + + // Canonical Opc.Ua.StatusCodes numerics, restated so the test asserts the wire value a client + // actually sees rather than a driver-private constant it could drift with. + private const uint Good = 0x00000000u; + private const uint BadNoCommunication = 0x80310000u; + private const uint BadWaitingForInitialData = 0x80320000u; + private const uint BadNodeIdUnknown = 0x80340000u; + + /// + /// Failure guard for the handful of awaits that a defect could turn into a permanent hang + /// (a NotSupported retry loop, a teardown that deadlocks on the lifecycle semaphore). + /// Deliberately far longer than any of these operations could legitimately take — it exists + /// to make a hang red, not to assert a duration. + /// + private static readonly TimeSpan Watchdog = TimeSpan.FromSeconds(10); + + private static readonly DateTime ObservedAt = new(2026, 7, 24, 12, 30, 0, DateTimeKind.Utc); + + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private static MTConnectDriverOptions Opts(MTConnectReconnectOptions? reconnect = null) => + new() + { + AgentUri = AgentUri, + RequestTimeoutMs = 5000, + Reconnect = reconnect ?? new MTConnectReconnectOptions(), + Tags = + [ + new MTConnectTagDefinition("dev1_pos", DriverDataType.Float64), + new MTConnectTagDefinition("dev1_execution", DriverDataType.String), + new MTConnectTagDefinition("dev1_partcount", DriverDataType.Int32), + new MTConnectTagDefinition("dev1_never_reported", DriverDataType.Int32), + ], + }; + + // ---- fixtures ---- + + private static (MTConnectDriver Driver, CannedAgentClient Client) NewDriver( + CannedAgentClient? client = null, + MTConnectDriverOptions? options = null, + RecordingDriverLogger? logger = null) + { + var agent = client ?? CannedAgentClient.FromFixtures(); + + return (new MTConnectDriver(options ?? Opts(), "mt1", _ => agent, logger), agent); + } + + private static async Task<(MTConnectDriver Driver, CannedAgentClient Client)> InitializedDriverAsync( + CannedAgentClient? client = null, + MTConnectDriverOptions? options = null, + RecordingDriverLogger? logger = null) + { + var (driver, agent) = NewDriver(client, options, logger); + await driver.InitializeAsync("{}", Ct); + + return (driver, agent); + } + + /// Attaches a recorder to the driver's data-change event and returns its log. + private static ConcurrentQueue Record(MTConnectDriver driver) + { + var seen = new ConcurrentQueue(); + driver.OnDataChange += (_, e) => seen.Enqueue(e); + + return seen; + } + + /// Builds a /sample chunk (or /current answer) for the fixture Agent. + private static MTConnectStreamsResult Chunk( + long firstSequence, long nextSequence, params (string Id, string Value)[] observations) => + ChunkFrom(FixtureInstanceId, firstSequence, nextSequence, observations); + + 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))]); + + private static IReadOnlyList For( + ConcurrentQueue seen, string reference) => + [.. seen.Where(e => e.FullReference == reference)]; + + // ---- initial data ---- + + /// + /// The plan's first TDD case, and the OPC UA Part 4 convention: a subscription reports the + /// current value immediately, out of the index the priming /current already filled — + /// it does not make the caller wait for the Agent's next chunk, which may be a whole + /// heartbeat away. + /// + [Fact] + public async Task Subscribe_fires_initial_data_from_current() + { + var (driver, _) = await InitializedDriverAsync(); + var seen = Record(driver); + + await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct); + + seen.Select(e => e.FullReference).ShouldContain("dev1_pos"); + var initial = seen.Single(); + initial.Snapshot.StatusCode.ShouldBe(Good); + initial.Snapshot.Value.ShouldBe(123.4567d); + } + + /// + /// Initial data fires for EVERY subscribed reference, including the ones with no value yet. + /// A driver that fired only for the references it happens to hold a value for would leave + /// the others with no snapshot at all — indistinguishable, from the server's side, from a + /// subscription that never established. + /// + [Fact] + public async Task Subscribe_fires_initial_data_for_every_ref_including_the_valueless_ones() + { + var (driver, _) = await InitializedDriverAsync(); + var seen = Record(driver); + + await driver.SubscribeAsync( + ["dev1_pos", "dev1_partcount", "dev1_never_reported", "nope"], TimeSpan.FromMilliseconds(50), Ct); + + seen.Count.ShouldBe(4); + For(seen, "dev1_pos")[0].Snapshot.StatusCode.ShouldBe(Good); + For(seen, "dev1_partcount")[0].Snapshot.StatusCode.ShouldBe(BadNoCommunication); + For(seen, "dev1_never_reported")[0].Snapshot.StatusCode.ShouldBe(BadWaitingForInitialData); + For(seen, "nope")[0].Snapshot.StatusCode.ShouldBe(BadNodeIdUnknown); + } + + /// Every event carries the handle the caller was given — that is how it demultiplexes. + [Fact] + public async Task Subscribe_returns_a_distinct_handle_per_call_and_stamps_it_on_every_event() + { + var (driver, _) = await InitializedDriverAsync(); + var seen = Record(driver); + + var a = await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct); + var b = await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct); + + a.ShouldNotBe(b); + a.DiagnosticId.ShouldNotBe(b.DiagnosticId); + a.DiagnosticId.ShouldNotBeNullOrWhiteSpace(); + For(seen, "dev1_pos").Select(e => e.SubscriptionHandle).ShouldBe([a, b]); + } + + /// Subscribing to nothing costs the Agent nothing — no stream is opened for it. + [Fact] + public async Task Subscribe_of_an_empty_ref_list_opens_no_stream() + { + var (driver, client) = await InitializedDriverAsync(); + + var handle = await driver.SubscribeAsync([], TimeSpan.FromMilliseconds(50), Ct); + + handle.ShouldNotBeNull(); + client.SampleCallCount.ShouldBe(0); + + // Asserted on the pump itself, not only on the call count: a pump that HAD been started + // might simply not have reached its first request yet, and would pass the count alone. + driver.SampleStreamTask.ShouldBeNull(); + + // …and it still unsubscribes cleanly, so a caller that conditionally adds refs later is symmetric. + await driver.UnsubscribeAsync(handle, Ct); + } + + // ---- the shared stream ---- + + /// + /// One Agent, one /sample long poll — no matter how many handles. MTConnect streams + /// the whole device, so a stream per subscription would multiply the Agent's connection load + /// by the number of OPC UA subscriptions for identical data. + /// + [Fact] + public async Task Two_subscriptions_share_exactly_one_sample_stream() + { + var (driver, client) = await InitializedDriverAsync(); + + await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct); + await driver.SubscribeAsync(["dev1_execution"], TimeSpan.FromMilliseconds(50), Ct); + + // Barrier: a chunk can only be consumed by a running enumeration, so both subscriptions have + // demonstrably landed on the same one by the time this returns. + await client.PumpAsync(Chunk(PrimedNextSequence, 113, ("dev1_pos", "1.5"))).WaitAsync(Watchdog, Ct); + + client.SampleCallCount.ShouldBe(1); + client.LastSampleFrom.ShouldBe(PrimedNextSequence); + } + + /// + /// A chunk updates the index for everything it carries, but only the subscribed references + /// raise a callback. The Agent streams every DataItem on the device; publishing all of them + /// would flood the server with values nothing asked for. + /// + [Fact] + public async Task Pump_raises_OnDataChange_only_for_subscribed_refs() + { + var (driver, client) = await InitializedDriverAsync(); + var seen = Record(driver); + await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct); + seen.Clear(); + + await client.PumpAsync(CannedAgentClient.Chunk("Fixtures/sample.xml")).WaitAsync(Watchdog, Ct); + + seen.Select(e => e.FullReference).Distinct().ShouldBe(["dev1_pos"]); + For(seen, "dev1_pos")[0].Snapshot.Value.ShouldBe(124.01d); + + // The index took the whole chunk even though only one ref was published. + driver.ObservationIndex.Get("dev1_partcount").Value.ShouldBe(42); + } + + /// Each handle hears about the references IT subscribed, and only those. + [Fact] + public async Task Pump_fans_each_observation_to_every_handle_that_subscribes_it() + { + var (driver, client) = await InitializedDriverAsync(); + var seen = Record(driver); + var a = await driver.SubscribeAsync(["dev1_pos", "dev1_execution"], TimeSpan.FromMilliseconds(50), Ct); + var b = await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct); + seen.Clear(); + + await client + .PumpAsync(Chunk(PrimedNextSequence, 113, ("dev1_pos", "1.5"), ("dev1_execution", "READY"))) + .WaitAsync(Watchdog, Ct); + + For(seen, "dev1_pos").Select(e => e.SubscriptionHandle).ShouldBe([a, b], ignoreOrder: true); + For(seen, "dev1_execution").Select(e => e.SubscriptionHandle).ShouldBe([a]); + } + + /// + /// The running-cursor pin. An observation-free heartbeat still advances the sequence — + /// the Agent sends one precisely so a quiet connection can be told from a dead one — and the + /// gap check must be made against the PREVIOUS chunk's nextSequence, never against the + /// from the stream was opened with. A driver that compares against the opening + /// from, or that skips an empty chunk when advancing, reports a gap on the third chunk + /// here and re-baselines against a perfectly healthy stream. + /// + [Fact] + public async Task Pump_advances_the_cursor_on_every_chunk_including_an_observation_free_one() + { + var (driver, client) = await InitializedDriverAsync(); + var seen = Record(driver); + await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct); + seen.Clear(); + + await client.PumpAsync(Chunk(PrimedNextSequence, 113, ("dev1_pos", "1.5"))).WaitAsync(Watchdog, Ct); + + // A heartbeat: no observations, and firstSequence is the buffer floor, not the cursor. + await client.PumpAsync(Chunk(1, 120)).WaitAsync(Watchdog, Ct); + + // Contiguous with the heartbeat's nextSequence — a gap ONLY if the heartbeat was ignored. + await client.PumpAsync(Chunk(120, 125, ("dev1_pos", "2.5"))).WaitAsync(Watchdog, Ct); + + client.CurrentCallCount.ShouldBe(1); // the initialize prime, and nothing else + client.SampleCallCount.ShouldBe(1); // one uninterrupted stream + For(seen, "dev1_pos").Last().Snapshot.Value.ShouldBe(2.5d); + } + + // ---- ring-buffer overflow ---- + + /// + /// The plan's second TDD case. The Agent's buffer rolled past the cursor + /// (firstSequence 5000 > the driver's 108), so the observations in between are + /// gone: the driver must re-baseline from /current rather than trust an incremental + /// update — and must do it once, then resume, not once per chunk forever. + /// + [Fact] + public async Task Sequence_gap_triggers_exactly_one_current_rebaseline_then_resumes() + { + var client = CannedAgentClient.WithGapThenResume(); + var (driver, _) = await InitializedDriverAsync(client); + var seen = Record(driver); + await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct); + seen.Clear(); + + await client.PumpOnce().WaitAsync(Watchdog, Ct); // the gap chunk + + client.CurrentCallCount.ShouldBeGreaterThan(1); // the plan's assertion + client.CurrentCallCount.ShouldBe(2); // prime + exactly one re-baseline + + await client.PumpOnce().WaitAsync(Watchdog, Ct); // the contiguous follow-up + + client.CurrentCallCount.ShouldBe(2); // no re-baseline storm + client.SampleCallCount.ShouldBe(2); // the stream was reopened once + For(seen, "dev1_pos").Last().Snapshot.Value.ShouldBe(201.5d); + } + + /// + /// …and it resumes from the re-baselined nextSequence, not from the stale + /// cursor the Agent has already evicted. Reopening at the old cursor would earn the identical + /// rejection immediately and forever. + /// + [Fact] + public async Task Sequence_gap_resumes_the_stream_from_the_rebaselined_next_sequence() + { + var client = CannedAgentClient.WithGapThenResume(); + var (driver, _) = await InitializedDriverAsync(client); + await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct); + + await client.PumpOnce().WaitAsync(Watchdog, Ct); // gap + await client.PumpOnce().WaitAsync(Watchdog, Ct); // resume — only a reopened stream delivers this + + client.LastSampleFrom.ShouldBe(5005L); + } + + /// + /// The other half of the overflow story. 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 as an + /// , never as a gap-bearing chunk. A pump that re-baselines + /// only on IsSequenceGap therefore fails precisely when it has fallen furthest behind. + /// + [Fact] + public async Task Out_of_range_stream_error_also_triggers_a_rebaseline() + { + var (driver, client) = await InitializedDriverAsync(); + var seen = Record(driver); + + client.SampleFailures.Enqueue(new InvalidDataException( + "MTConnect /sample answered an MTConnectError document (OUT_OF_RANGE) under HTTP 200")); + client.CurrentAnswers.Enqueue(Chunk(490, 500, ("dev1_pos", "50.5"))); + + await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct); + seen.Clear(); + + // Only a pump that re-baselined and reopened the stream can consume this. + await client.PumpAsync(Chunk(500, 505, ("dev1_pos", "77.5"))).WaitAsync(Watchdog, Ct); + + client.CurrentCallCount.ShouldBe(2); + client.SampleCallCount.ShouldBe(2); + client.LastSampleFrom.ShouldBe(500L); + For(seen, "dev1_pos").Last().Snapshot.Value.ShouldBe(77.5d); + } + + // ---- agent restart ---- + + /// + /// The instanceId check must come BEFORE the gap check. An Agent restart changes + /// instanceId AND resets sequences, so a restart usually trips + /// IsSequenceGap too — and the two demand 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. This chunk is deliberately BOTH: new + /// instanceId and a firstSequence far past the cursor. A driver that tests the gap + /// first keeps serving dev1_execution's pre-restart value indefinitely — the new + /// Agent never reports it again, so nothing would ever overwrite it. + /// + [Fact] + public async Task Agent_restart_clears_the_index_and_is_detected_before_the_gap_path() + { + var (driver, client) = await InitializedDriverAsync(); + var seen = Record(driver); + await driver.SubscribeAsync(["dev1_pos", "dev1_execution"], TimeSpan.FromMilliseconds(50), Ct); + seen.Clear(); + + // The restarted Agent's /current knows nothing about dev1_execution. + 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.AgentInstanceId.ShouldBe(RestartedInstanceId); + client.CurrentCallCount.ShouldBe(2); + + // Cleared: the pre-restart value is gone, not merely shadowed. + driver.ObservationIndex.Get("dev1_execution").StatusCode.ShouldBe(BadWaitingForInitialData); + driver.ObservationIndex.Get("dev1_pos").Value.ShouldBe(9.5d); + + // And the subscriber was TOLD its value went away, rather than being left holding a stale Good. + For(seen, "dev1_execution").Last().Snapshot.StatusCode.ShouldBe(BadWaitingForInitialData); + For(seen, "dev1_pos").Last().Snapshot.Value.ShouldBe(9.5d); + } + + // ---- stream ends ---- + + /// + /// A dropped connection is transient: reconnect from the cursor and keep going. It is + /// emphatically NOT a re-baseline — the sequence is still valid, and spending a + /// /current on every network blip would be a self-inflicted load. + /// + [Fact] + public async Task Stream_ended_reconnects_from_the_cursor_without_a_rebaseline() + { + var (driver, client) = await InitializedDriverAsync(); + var seen = Record(driver); + await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct); + seen.Clear(); + + await client.PumpAsync(Chunk(PrimedNextSequence, 113, ("dev1_pos", "1.5"))).WaitAsync(Watchdog, Ct); + + client.EndStream(); + + // Only a reconnected pump can consume a chunk written to the NEW stream generation. + await client.PumpAsync(Chunk(113, 118, ("dev1_pos", "2.5"))).WaitAsync(Watchdog, Ct); + + client.SampleCallCount.ShouldBe(2); + client.LastSampleFrom.ShouldBe(113L); + client.CurrentCallCount.ShouldBe(1); + For(seen, "dev1_pos").Last().Snapshot.Value.ShouldBe(2.5d); + } + + /// + /// The hot-loop pin. means the + /// endpoint will never stream to this request — a reverse proxy, a health page, an Agent + /// fronted by something that collapses multipart/x-mixed-replace. Reconnecting + /// reproduces the identical answer forever, so the pump must give up loudly instead of + /// hammering the endpoint until an operator notices. A later subscription must not restart + /// the loop either: the answer has not changed just because someone asked again. + /// + [Fact] + public async Task Stream_not_supported_stops_the_pump_and_never_retries() + { + var (driver, client) = await InitializedDriverAsync(); + client.SampleFailure = new MTConnectStreamNotSupportedException( + "the Agent answered /sample with a single non-multipart document"); + + var handle = await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct); + var pump = driver.SampleStreamTask.ShouldNotBeNull(); + + await pump.WaitAsync(Watchdog, Ct); + + client.SampleCallCount.ShouldBe(1); + driver.GetHealth().State.ShouldBe(DriverState.Degraded); + driver.GetHealth().LastError.ShouldNotBeNullOrWhiteSpace(); + + // A full unsubscribe/resubscribe cycle must not re-open the wound either. This is exactly + // what DriverInstanceActor does for a tag-set change: UnsubscribeAsync then SubscribeAsync, + // with NO re-initialize in between. + await driver.UnsubscribeAsync(handle, Ct).WaitAsync(Watchdog, Ct); + await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct); + + client.SampleCallCount.ShouldBe(1); + + // ...and the driver must not read back GREEN afterwards. The unsubscribe stopped a stream + // that was never running, the resubscribe was silently refused by the latch, and one + // successful read was then enough to clear the last degradation flag — leaving a Healthy + // driver holding a subscription that can never deliver a value. That is #485 in the + // subscription plane: an established handle, an Established reply, and permanent silence. + var res = await driver.ReadAsync(["dev1_pos"], Ct); + + res[0].StatusCode.ShouldBe(Good); + driver.GetHealth().State.ShouldBe(DriverState.Degraded); + driver.GetHealth().LastError.ShouldNotBeNullOrWhiteSpace(); + } + + /// + /// …but a re-initialize IS an operator changing something, so it clears the verdict and + /// tries again. Otherwise fixing the proxy would need a process restart. + /// + [Fact] + public async Task Reinitialize_clears_the_stream_unsupported_verdict() + { + var (driver, client) = await InitializedDriverAsync(); + client.SampleFailure = new MTConnectStreamNotSupportedException("not multipart"); + await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct); + await driver.SampleStreamTask!.WaitAsync(Watchdog, Ct); + + client.SampleFailure = null; + await driver.ReinitializeAsync("{}", Ct).WaitAsync(Watchdog, Ct); + + await client.PumpAsync(Chunk(PrimedNextSequence, 113, ("dev1_pos", "1.5"))).WaitAsync(Watchdog, Ct); + + client.SampleCallCount.ShouldBe(2); + } + + // ---- backoff ---- + + /// + /// The reconnect backoff as a pure function of the attempt number: the first retry honours + /// MinBackoffMs (zero = immediate), and every later one grows geometrically to the + /// cap. The 100 ms growth floor matters more than it looks — the default + /// MinBackoffMs is 0, and 0 × multiplier is still 0, so without a floor + /// the "geometric" backoff would be an unbounded hot loop against a dead Agent. + /// + [Fact] + public void Reconnect_backoff_starts_at_min_grows_geometrically_and_caps() + { + var options = new MTConnectReconnectOptions + { + MinBackoffMs = 0, MaxBackoffMs = 1000, BackoffMultiplier = 2.0, + }; + + MTConnectDriver.BackoffFor(1, options).ShouldBe(TimeSpan.Zero); + MTConnectDriver.BackoffFor(2, options).ShouldBe(TimeSpan.FromMilliseconds(200)); + MTConnectDriver.BackoffFor(3, options).ShouldBe(TimeSpan.FromMilliseconds(400)); + MTConnectDriver.BackoffFor(4, options).ShouldBe(TimeSpan.FromMilliseconds(800)); + MTConnectDriver.BackoffFor(5, options).ShouldBe(TimeSpan.FromMilliseconds(1000)); + MTConnectDriver.BackoffFor(500, options).ShouldBe(TimeSpan.FromMilliseconds(1000)); + } + + /// + /// Operator-authored nonsense must not un-bound the loop: a multiplier of 1 (or less) would + /// never grow, and a negative delay is not a delay. Both fall back to the shipped default. + /// + [Fact] + public void Reconnect_backoff_survives_a_degenerate_multiplier() + { + var options = new MTConnectReconnectOptions + { + MinBackoffMs = -5, MaxBackoffMs = 10_000, BackoffMultiplier = 0.5, + }; + + MTConnectDriver.BackoffFor(1, options).ShouldBe(TimeSpan.Zero); + MTConnectDriver.BackoffFor(2, options).ShouldBeGreaterThan(TimeSpan.Zero); + MTConnectDriver.BackoffFor(3, options) + .ShouldBeGreaterThan(MTConnectDriver.BackoffFor(2, options)); + } + + // ---- unsubscribe ---- + + /// + /// Dropping one handle drops only its references; the shared stream keeps running for the + /// others. Tearing the stream down on the first unsubscribe would silently blind every + /// remaining subscription. + /// + [Fact] + public async Task Unsubscribe_drops_only_that_handles_refs_and_keeps_the_shared_stream() + { + var (driver, client) = await InitializedDriverAsync(); + var seen = Record(driver); + var a = await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct); + var b = await driver.SubscribeAsync(["dev1_execution"], TimeSpan.FromMilliseconds(50), Ct); + var pump = driver.SampleStreamTask.ShouldNotBeNull(); + + await driver.UnsubscribeAsync(a, Ct).WaitAsync(Watchdog, Ct); + seen.Clear(); + + await client + .PumpAsync(Chunk(PrimedNextSequence, 113, ("dev1_pos", "1.5"), ("dev1_execution", "READY"))) + .WaitAsync(Watchdog, Ct); + + pump.IsCompleted.ShouldBeFalse(); + seen.Select(e => e.FullReference).Distinct().ShouldBe(["dev1_execution"]); + + await driver.UnsubscribeAsync(b, Ct).WaitAsync(Watchdog, Ct); + + pump.IsCompleted.ShouldBeTrue(); + driver.SampleStreamTask.ShouldBeNull(); + } + + /// The last unsubscribe stops the stream — and nothing is published after it. + [Fact] + public async Task Unsubscribe_of_the_last_handle_stops_the_stream_and_silences_the_driver() + { + var (driver, client) = await InitializedDriverAsync(); + var seen = Record(driver); + var handle = await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct); + + await driver.UnsubscribeAsync(handle, Ct).WaitAsync(Watchdog, Ct); + seen.Clear(); + + // The stream is gone, so this chunk has no consumer and nothing may reach the recorder. + client.PumpAsync(Chunk(PrimedNextSequence, 113, ("dev1_pos", "1.5"))).IsCompleted.ShouldBeFalse(); + + seen.ShouldBeEmpty(); + driver.SampleStreamTask.ShouldBeNull(); + } + + /// + /// Unsubscribing twice, or with a handle this driver never issued, is a no-op — not a throw. + /// Teardown paths run on failure paths, and an exception there would mask the real fault. + /// + [Fact] + public async Task Unsubscribe_is_idempotent_and_ignores_a_foreign_handle() + { + var (driver, _) = await InitializedDriverAsync(); + var handle = await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct); + + await driver.UnsubscribeAsync(handle, Ct).WaitAsync(Watchdog, Ct); + await driver.UnsubscribeAsync(handle, Ct).WaitAsync(Watchdog, Ct); + await driver.UnsubscribeAsync(new ForeignHandle(), Ct).WaitAsync(Watchdog, Ct); + } + + /// A null argument is a caller bug, and the one thing these methods are loud about. + [Fact] + public async Task Null_arguments_throw_ArgumentNullException() + { + var (driver, _) = await InitializedDriverAsync(); + + await Should.ThrowAsync( + () => driver.SubscribeAsync(null!, TimeSpan.FromMilliseconds(50), Ct)); + await Should.ThrowAsync(() => driver.UnsubscribeAsync(null!, Ct)); + } + + // ---- lifecycle ---- + + /// + /// Subscribing before the driver is initialized registers the interest and reports the + /// honest "no value yet" — it does not throw, and it does not dial an Agent that does not + /// exist yet. Initialize then picks the standing subscription up. + /// + [Fact] + public async Task Subscribe_before_initialize_registers_and_initialize_starts_the_stream() + { + var (driver, client) = NewDriver(); + var seen = Record(driver); + + await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct); + + client.SampleCallCount.ShouldBe(0); + driver.SampleStreamTask.ShouldBeNull(); + seen.Single().Snapshot.StatusCode.ShouldBe(BadWaitingForInitialData); + + await driver.InitializeAsync("{}", Ct); + await client.PumpAsync(Chunk(PrimedNextSequence, 113, ("dev1_pos", "1.5"))).WaitAsync(Watchdog, Ct); + + client.SampleCallCount.ShouldBe(1); + For(seen, "dev1_pos").Last().Snapshot.Value.ShouldBe(1.5d); + } + + /// + /// A re-initialize replaces the served state, so it must replace the pump with it: the old + /// one is stopped (it holds the old cursor, and possibly the old client) and a new one starts + /// from the fresh /current. The standing subscription survives — the caller did not + /// ask for it to be dropped — and is re-primed, because every value it holds came from a + /// baseline that has just been thrown away. + /// + [Fact] + public async Task Reinitialize_stops_the_old_pump_starts_a_new_one_and_republishes() + { + var (driver, client) = await InitializedDriverAsync(); + var seen = Record(driver); + await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct); + var first = driver.SampleStreamTask.ShouldNotBeNull(); + + // Barrier: the first pump is demonstrably enumerating before the re-initialize lands on it. + await client.PumpAsync(Chunk(PrimedNextSequence, 113, ("dev1_pos", "1.5"))).WaitAsync(Watchdog, Ct); + + client.CurrentAnswers.Enqueue(Chunk(300, 310, ("dev1_pos", "310.5"))); + seen.Clear(); + + await driver.ReinitializeAsync("{}", Ct).WaitAsync(Watchdog, Ct); + + first.IsCompleted.ShouldBeTrue(); + var second = driver.SampleStreamTask.ShouldNotBeNull(); + second.ShouldNotBeSameAs(first); + + await client.PumpAsync(Chunk(310, 315, ("dev1_pos", "315.5"))).WaitAsync(Watchdog, Ct); + + client.SampleCallCount.ShouldBe(2); + client.LastSampleFrom.ShouldBe(310L); + For(seen, "dev1_pos").Select(e => e.Snapshot.Value).ShouldBe([310.5d, 315.5d]); + } + + /// + /// Shutdown stops the pump BEFORE disposing the client. Getting that order wrong leaves the + /// pump enumerating a disposed client — which it would read as a dropped connection and try + /// to reconnect, forever, against an object that no longer exists. + /// + [Fact] + public async Task Shutdown_stops_the_pump_before_disposing_the_client_and_leaks_nothing() + { + var (driver, client) = await InitializedDriverAsync(); + var seen = Record(driver); + await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct); + var pump = driver.SampleStreamTask.ShouldNotBeNull(); + + // Barrier: the stream is demonstrably open before the shutdown lands, so the call count + // below is a statement about reconnect churn rather than about who won a race. + await client.PumpAsync(Chunk(PrimedNextSequence, 113, ("dev1_pos", "1.5"))).WaitAsync(Watchdog, Ct); + seen.Clear(); + + await driver.ShutdownAsync(Ct).WaitAsync(Watchdog, Ct); + + pump.IsCompleted.ShouldBeTrue(); + pump.IsFaulted.ShouldBeFalse(); + driver.SampleStreamTask.ShouldBeNull(); + client.IsDisposed.ShouldBeTrue(); + + // No reconnect churn against the disposed client, and nothing published after teardown. + client.SampleCallCount.ShouldBe(1); + seen.ShouldBeEmpty(); + } + + /// + /// The deadlock pin. The lifecycle semaphore is non-reentrant, so a pump that reached + /// back into InitializeAsync/ReinitializeAsync/ShutdownAsync — the + /// natural way to write "on a gap, just re-prime" — would hang the driver permanently, with + /// no exception and no log. Here the pump is parked INSIDE its re-baseline /current + /// when a shutdown lands and takes that semaphore: the shutdown must cancel the pump and + /// complete. If the pump were waiting on the lifecycle lock instead, neither side would ever + /// move. + /// + [Fact] + public async Task Shutdown_while_the_pump_is_mid_rebaseline_does_not_deadlock() + { + var (driver, client) = await InitializedDriverAsync(); + await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct); + + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + client.CurrentEntered = entered; + client.CurrentGate = release; + + // A gap forces the re-baseline; the gate holds the pump inside the /current request. + _ = client.PumpAsync(Chunk(5000, 5005, ("dev1_pos", "5.5"))); + await entered.Task.WaitAsync(Watchdog, Ct); + + try + { + await driver.ShutdownAsync(Ct).WaitAsync(Watchdog, Ct); + } + finally + { + release.TrySetResult(); + } + + driver.SampleStreamTask.ShouldBeNull(); + client.IsDisposed.ShouldBeTrue(); + } + + // ---- robustness + health ---- + + /// + /// A subscriber that throws is a bug in the consumer, not a reason to stop delivering data to + /// every other subscriber. The pump absorbs it and keeps pumping. + /// + [Fact] + public async Task A_throwing_subscriber_does_not_kill_the_pump() + { + var (driver, client) = await InitializedDriverAsync(); + // Registration ORDER is the whole point: the thrower goes FIRST. A multicast invoke aborts + // the rest of the invocation list at the first exception, so with the recorder registered + // first it would run before the throw and its assertions would hold however badly the + // driver isolates subscribers — the test would pass without testing anything. + driver.OnDataChange += (_, _) => throw new InvalidOperationException("subscriber blew up"); + var seen = Record(driver); + + await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct); + seen.Clear(); + + await client.PumpAsync(Chunk(PrimedNextSequence, 113, ("dev1_pos", "1.5"))).WaitAsync(Watchdog, Ct); + await client.PumpAsync(Chunk(113, 118, ("dev1_pos", "2.5"))).WaitAsync(Watchdog, Ct); + + driver.SampleStreamTask!.IsCompleted.ShouldBeFalse(); + For(seen, "dev1_pos").Select(e => e.Snapshot.Value).ShouldBe([1.5d, 2.5d]); + } + + /// + /// Health precedence, read side. A healthy stream must not paper over a broken read + /// path: /sample and /current are different Agent requests and can fail + /// independently, and "Healthy" while every OPC UA Read returns Bad is exactly the + /// failure-wearing-success shape this driver is written against. + /// + [Fact] + public async Task A_pumped_chunk_does_not_paper_over_a_failed_read() + { + var (driver, client) = await InitializedDriverAsync(); + await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct); + + client.CurrentFailure = new HttpRequestException("connection refused"); + await driver.ReadAsync(["dev1_pos"], Ct); + driver.GetHealth().State.ShouldBe(DriverState.Degraded); + + await client.PumpAsync(Chunk(PrimedNextSequence, 113, ("dev1_pos", "1.5"))).WaitAsync(Watchdog, Ct); + + driver.GetHealth().State.ShouldBe(DriverState.Degraded); + + // …and clearing the read failure does restore it, because the stream never failed. + client.CurrentFailure = null; + await driver.ReadAsync(["dev1_pos"], Ct); + driver.GetHealth().State.ShouldBe(DriverState.Healthy); + } + + /// + /// Health precedence, stream side. The mirror image: a successful read must not paper + /// over a stream that has stopped delivering. Only the path that failed may clear its own + /// degradation. + /// + [Fact] + public async Task A_successful_read_does_not_paper_over_a_broken_stream() + { + var (driver, client) = await InitializedDriverAsync(); + client.SampleFailure = new MTConnectStreamNotSupportedException("not multipart"); + await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct); + await driver.SampleStreamTask!.WaitAsync(Watchdog, Ct); + + var res = await driver.ReadAsync(["dev1_pos"], Ct); + + res[0].StatusCode.ShouldBe(Good); + driver.GetHealth().State.ShouldBe(DriverState.Degraded); + } + + /// A recovered stream clears its own degradation once chunks flow again. + [Fact] + public async Task A_recovered_stream_restores_health() + { + var (driver, client) = await InitializedDriverAsync(); + client.SampleFailures.Enqueue(new TimeoutException("no chunk within the heartbeat window")); + + await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct); + await client.PumpAsync(Chunk(PrimedNextSequence, 113, ("dev1_pos", "1.5"))).WaitAsync(Watchdog, Ct); + + driver.GetHealth().State.ShouldBe(DriverState.Healthy); + client.SampleCallCount.ShouldBe(2); + } + + /// + /// C1, the other half. Refusing to start the stream is correct, but it must not be + /// silent: the operator's only other evidence is a subscription that never delivers. The + /// refusal re-asserts the degradation AND says so, naming the remedy. + /// + [Fact] + public async Task A_refused_stream_start_re_asserts_degraded_and_says_so() + { + var logger = new RecordingDriverLogger(); + var (driver, client) = await InitializedDriverAsync(logger: logger); + client.SampleFailure = new MTConnectStreamNotSupportedException("not multipart"); + + var handle = await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct); + await driver.SampleStreamTask!.WaitAsync(Watchdog, Ct); + + await driver.UnsubscribeAsync(handle, Ct).WaitAsync(Watchdog, Ct); + await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct); + + driver.GetHealth().State.ShouldBe(DriverState.Degraded); + logger.WarningsSnapshot() + .ShouldContain(w => w.Contains("refused to start a /sample stream", StringComparison.Ordinal)); + } + + /// + /// C1, the window the refusal path cannot cover. Dropping the last subscription stops + /// a stream, and teardown clears the stream degradation with it — but NOT while the endpoint + /// is latched as unable to stream at all. That latch is a standing fact about the Agent, + /// discovered at runtime and true whether or not anyone is subscribed right now; letting a + /// successful read clear it here would make the driver flicker green between subscriptions + /// and report the impairment only while someone happens to be listening. + /// + [Fact] + public async Task A_latched_endpoint_stays_degraded_even_with_no_subscriptions_left() + { + var (driver, client) = await InitializedDriverAsync(); + client.SampleFailure = new MTConnectStreamNotSupportedException("not multipart"); + + var handle = await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct); + await driver.SampleStreamTask!.WaitAsync(Watchdog, Ct); + + // No subscriptions left at all — so nothing re-asserts the degradation on a refused start. + await driver.UnsubscribeAsync(handle, Ct).WaitAsync(Watchdog, Ct); + + var res = await driver.ReadAsync(["dev1_pos"], Ct); + + res[0].StatusCode.ShouldBe(Good); + driver.GetHealth().State.ShouldBe(DriverState.Degraded); + } + + /// + /// I1 — the caller's teardown deadline must be real. DriverInstanceActor wraps + /// this call in a 5 s and blocks an Akka dispatcher + /// thread on the shutdown path, so a wait that ignored the token would let one blocking + /// OnDataChange handler wedge an actor-system thread — while holding the lifecycle + /// semaphore, taking every later lifecycle call on this driver with it. + /// + [Fact] + public async Task Unsubscribe_honours_the_callers_deadline_when_a_subscriber_blocks() + { + var (driver, client) = await InitializedDriverAsync(); + var blocking = new ManualResetEventSlim(false); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + // Blocks only on the PUMPED value — the initial-data callback fires on the subscribe + // thread, and blocking there would prove nothing about teardown. + driver.OnDataChange += (_, e) => + { + if (e.Snapshot.Value is not 1.5d) + { + return; + } + + entered.TrySetResult(); + blocking.Wait(); + }; + + var handle = await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct); + + try + { + _ = client.PumpAsync(Chunk(PrimedNextSequence, 113, ("dev1_pos", "1.5"))); + await entered.Task.WaitAsync(Watchdog, Ct); + + // The pump is now stuck inside caller code. The unsubscribe must still return. + using var deadline = new CancellationTokenSource(TimeSpan.FromMilliseconds(100)); + await driver.UnsubscribeAsync(handle, deadline.Token).WaitAsync(Watchdog, Ct); + + driver.SampleStreamTask.ShouldBeNull(); + } + finally + { + blocking.Set(); + } + } + + /// + /// I2 — the backoff ladder is per-outage, not per-process. A stream that delivered + /// before it dropped proves the endpoint works, so its failure starts a fresh ladder. Without + /// this the counter only ever climbs: an agent that drops one connection an hour would be + /// pinned at MaxBackoffMs within a day, having never once failed twice in a row. + /// + [Fact] + public async Task Reconnect_attempts_reset_when_the_stream_delivered_before_dropping() + { + var (driver, client) = await InitializedDriverAsync(); + await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct); + + await client.PumpAsync(Chunk(PrimedNextSequence, 113, ("dev1_pos", "1.5"))).WaitAsync(Watchdog, Ct); + client.EndStream(); + await client.PumpAsync(Chunk(113, 118, ("dev1_pos", "2.5"))).WaitAsync(Watchdog, Ct); + client.EndStream(); + await client.PumpAsync(Chunk(118, 123, ("dev1_pos", "3.5"))).WaitAsync(Watchdog, Ct); + + // Two unrelated drops, each preceded by a delivered chunk: each starts a fresh ladder, so + // the count is 1 (this outage's first retry, which still honours MinBackoffMs) — never 2. + driver.ReconnectAttempts.ShouldBe(1); + client.SampleCallCount.ShouldBe(3); + } + + /// + /// …and the counter still accumulates when nothing is delivered, which is the case the + /// backoff exists for. Asserted as monotonic growth rather than an exact value, because the + /// pump may have advanced again between the barrier and the read. + /// + [Fact] + public async Task Reconnect_attempts_accumulate_while_the_stream_delivers_nothing() + { + // A 1 ms cap keeps the failing loop from actually sleeping; nothing here asserts a duration. + var options = Opts(new MTConnectReconnectOptions { MinBackoffMs = 0, MaxBackoffMs = 1 }); + var (driver, client) = await InitializedDriverAsync(options: options); + client.SampleFailure = new TimeoutException("no chunk within the heartbeat window"); + + var handle = await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct); + + try + { + await client.WaitForSampleCallsAsync(3).WaitAsync(Watchdog, Ct); + var early = driver.ReconnectAttempts; + + await client.WaitForSampleCallsAsync(6).WaitAsync(Watchdog, Ct); + + early.ShouldBeGreaterThanOrEqualTo(2); + driver.ReconnectAttempts.ShouldBeGreaterThan(early); + } + finally + { + await driver.UnsubscribeAsync(handle, Ct).WaitAsync(Watchdog, Ct); + } + } + + /// + /// I3 — a non-positive cap is an operator-authorable hot loop. Clamping to it (the + /// obvious Math.Max(0, …)) makes EVERY delay zero, so the pump reconnect-spins as fast + /// as a refused connection returns, one Warning per iteration. Treated as "unset" instead, + /// exactly like a multiplier that cannot grow. + /// + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Reconnect_backoff_treats_a_non_positive_cap_as_unset(int maxBackoffMs) + { + var options = new MTConnectReconnectOptions + { + MinBackoffMs = 0, MaxBackoffMs = maxBackoffMs, BackoffMultiplier = 2.0, + }; + + // The first retry is still immediate — that is MinBackoffMs, and it is honoured. + MTConnectDriver.BackoffFor(1, options).ShouldBe(TimeSpan.Zero); + + // Every later one must actually back off. + MTConnectDriver.BackoffFor(2, options).ShouldBeGreaterThan(TimeSpan.Zero); + MTConnectDriver.BackoffFor(9, options).ShouldBeGreaterThan(MTConnectDriver.BackoffFor(2, options)); + } + + /// + /// I4 — the session's instanceId and cursor are one fact and must move together. + /// Publishing the id alone left the session holding the NEW agent's id beside the OLD + /// agent's cursor, which is precisely the tear the record exists to prevent — and on a gap + /// or OUT_OF_RANGE re-baseline (where the id does not change at all) the cursor was + /// not published even once. + /// + [Fact] + public async Task Rebaseline_publishes_the_cursor_beside_the_instance_id() + { + var client = CannedAgentClient.WithGapThenResume(); + var (driver, _) = await InitializedDriverAsync(client); + await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct); + + driver.AgentNextSequence.ShouldBe(PrimedNextSequence); + + await client.PumpOnce().WaitAsync(Watchdog, Ct); // the gap chunk + + driver.AgentNextSequence.ShouldBe(5005L); + driver.AgentInstanceId.ShouldBe(FixtureInstanceId); + } + + /// + /// …and the consequence that makes it matter: the NEXT pump opens at the re-baselined + /// cursor. A last-unsubscribe followed by a resubscribe is an ordinary tag-set change + /// (DriverInstanceActor does exactly that, with no re-initialize), and a stale cursor + /// there means reopening at a sequence the Agent has already evicted. + /// + [Fact] + public async Task A_restarted_pump_resumes_from_the_rebaselined_cursor() + { + var client = CannedAgentClient.WithGapThenResume(); + var (driver, _) = await InitializedDriverAsync(client); + var handle = await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct); + + await client.PumpOnce().WaitAsync(Watchdog, Ct); // gap -> re-baseline to 5005 + + await driver.UnsubscribeAsync(handle, Ct).WaitAsync(Watchdog, Ct); + await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct); + + await client.PumpOnce().WaitAsync(Watchdog, Ct); // the contiguous follow-up + + client.LastSampleFrom.ShouldBe(5005L); + } + + /// + /// I5 — one broken subscriber must not starve the others. A multicast + /// Invoke aborts the invocation list at the first exception, so a single try/catch + /// around it absorbs the exception while silently robbing every later subscriber of the + /// value — which reads as "isolated" in the log and is not. The thrower sits BETWEEN the two + /// recorders on purpose. + /// + [Fact] + public async Task Every_subscriber_receives_a_value_even_when_one_in_the_middle_throws() + { + var (driver, client) = await InitializedDriverAsync(); + var first = Record(driver); + driver.OnDataChange += (_, _) => throw new InvalidOperationException("subscriber blew up"); + var last = Record(driver); + + await driver.SubscribeAsync(["dev1_pos"], TimeSpan.FromMilliseconds(50), Ct); + first.Clear(); + last.Clear(); + + await client.PumpAsync(Chunk(PrimedNextSequence, 113, ("dev1_pos", "1.5"))).WaitAsync(Watchdog, Ct); + + For(first, "dev1_pos").Select(e => e.Snapshot.Value).ShouldBe([1.5d]); + For(last, "dev1_pos").Select(e => e.Snapshot.Value).ShouldBe([1.5d]); + } + + /// A handle from some other driver — the type check must not be a cast. + private sealed class ForeignHandle : ISubscriptionHandle + { + public string DiagnosticId => "not-ours"; + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/RecordingDriverLogger.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/RecordingDriverLogger.cs new file mode 100644 index 00000000..a2193020 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/RecordingDriverLogger.cs @@ -0,0 +1,65 @@ +using Microsoft.Extensions.Logging; + +namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests; + +/// +/// Captures what the driver logs, so that "and it says so" can be asserted rather than assumed. +/// +/// +/// Used where the log line is the behaviour: a browse scope that matches nothing, and a +/// subscription the driver silently refuses to serve. Both are cases whose only other symptom is +/// an operator staring at an empty panel, so a fix that emits nothing is not a fix. +/// +internal sealed class RecordingDriverLogger : ILogger +{ + /// Formatted lines, in order. + public List Warnings { get; } = []; + + /// Formatted lines, in order. + public List Errors { get; } = []; + + /// + public IDisposable? BeginScope(TState state) + where TState : notnull => null; + + /// + public bool IsEnabled(LogLevel logLevel) => true; + + /// + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + ArgumentNullException.ThrowIfNull(formatter); + + var sink = logLevel switch + { + LogLevel.Warning => Warnings, + LogLevel.Error => Errors, + _ => null, + }; + + // Locked: the /sample pump logs from its own task while the test thread reads. + if (sink is null) + { + return; + } + + lock (sink) + { + sink.Add(formatter(state, exception)); + } + } + + /// A snapshot copy of , safe to enumerate while the pump runs. + public IReadOnlyList WarningsSnapshot() + { + lock (Warnings) + { + return [.. Warnings]; + } + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests.csproj new file mode 100644 index 00000000..866f7a9e --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests.csproj @@ -0,0 +1,42 @@ + + + + + $(NoWarn);OTOPCUA0001 + + + + net10.0 + enable + enable + false + true + ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + PreserveNewest + + + + diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/MTConnectDriverFormModelTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/MTConnectDriverFormModelTests.cs new file mode 100644 index 00000000..b291a90b --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/MTConnectDriverFormModelTests.cs @@ -0,0 +1,322 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Forms; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests; + +/// +/// Unit tests for the plain-C# core of — the pure form model and +/// its DriverConfig JSON projection. There is no bUnit in this project, so every decision the +/// form makes lives here (in code a test can reach) rather than in razor markup. +/// +/// +/// The JSON shape asserted below is the one MTConnectDriver.ParseOptions binds +/// (agentUri / deviceName / requestTimeoutMs / sampleIntervalMs / +/// sampleCount / heartbeatMs / probe / reconnect). It is deliberately +/// NOT MTConnectDriverOptions: that type carries TimeSpan probe knobs, which serialise +/// as "00:00:05" and would not bind to the driver's intervalMs integers. +/// +public sealed class MTConnectDriverFormModelTests +{ + // Mirrors MTConnectDriverForm._jsonOpts for the read side of the assertions. + private static readonly JsonSerializerOptions _opts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + WriteIndented = false, + Converters = { new JsonStringEnumConverter() }, + }; + + private static JsonObject Emit(MTConnectDriverForm.MTConnectFormModel form, string? existingJson = null) + => JsonNode.Parse(MTConnectDriverForm.BuildConfigJson(form, existingJson))!.AsObject(); + + // ---- default shape ------------------------------------------------------------------------- + + [Fact] + public void Default_form_emits_a_config_the_driver_can_parse() + { + var o = Emit(new MTConnectDriverForm.MTConnectFormModel { AgentUri = "http://agent:5000" }); + + o["agentUri"]!.GetValue().ShouldBe("http://agent:5000"); + o["requestTimeoutMs"]!.GetValue().ShouldBe(5000); + o["sampleIntervalMs"]!.GetValue().ShouldBe(1000); + o["sampleCount"]!.GetValue().ShouldBe(1000); + o["heartbeatMs"]!.GetValue().ShouldBe(10000); + o["probe"]!["enabled"]!.GetValue().ShouldBeTrue(); + o["probe"]!["intervalMs"]!.GetValue().ShouldBe(5000); + o["probe"]!["timeoutMs"]!.GetValue().ShouldBe(2000); + o["reconnect"]!["maxBackoffMs"]!.GetValue().ShouldBe(30000); + o["reconnect"]!["backoffMultiplier"]!.GetValue().ShouldBe(2.0); + } + + [Fact] + public void Blank_agent_uri_is_omitted_rather_than_written_as_an_empty_string() + { + // An empty "agentUri" and an absent one both fail ParseOptions, but omitting it keeps the + // driver's own "missing required AgentUri" message accurate. + var o = Emit(new MTConnectDriverForm.MTConnectFormModel { AgentUri = " " }); + o.ContainsKey("agentUri").ShouldBeFalse(); + } + + [Fact] + public void Agent_uri_and_device_name_are_trimmed_and_blank_device_name_is_omitted() + { + var o = Emit(new MTConnectDriverForm.MTConnectFormModel + { + AgentUri = " http://agent:5000 ", + DeviceName = " ", + }); + + o["agentUri"]!.GetValue().ShouldBe("http://agent:5000"); + o.ContainsKey("deviceName").ShouldBeFalse(); + } + + // ---- arch-review 01/S-6: a zero must never reach the driver ------------------------------- + + [Theory] + [InlineData("requestTimeoutMs")] + [InlineData("sampleIntervalMs")] + [InlineData("sampleCount")] + [InlineData("heartbeatMs")] + public void Non_positive_timing_knobs_are_omitted_so_the_driver_applies_its_own_default(string key) + { + var form = new MTConnectDriverForm.MTConnectFormModel { AgentUri = "http://agent:5000" }; + switch (key) + { + case "requestTimeoutMs": form.RequestTimeoutMs = 0; break; + case "sampleIntervalMs": form.SampleIntervalMs = -1; break; + case "sampleCount": form.SampleCount = 0; break; + case "heartbeatMs": form.HeartbeatMs = 0; break; + } + + // MTConnectDriver.StartCore calls RequirePositive on all four and faults the driver at + // Initialize; omitting the key means ParseOptions substitutes the positive default instead. + Emit(form).ContainsKey(key).ShouldBeFalse(); + } + + [Fact] + public void Non_positive_probe_knobs_are_omitted_so_the_driver_applies_its_own_default() + { + var o = Emit(new MTConnectDriverForm.MTConnectFormModel + { + AgentUri = "http://agent:5000", + ProbeIntervalMs = 0, + ProbeTimeoutMs = -5, + }); + + var probe = o["probe"]!.AsObject(); + probe["enabled"]!.GetValue().ShouldBeTrue(); + probe.ContainsKey("intervalMs").ShouldBeFalse(); + probe.ContainsKey("timeoutMs").ShouldBeFalse(); + } + + [Fact] + public void Zero_reconnect_min_backoff_is_preserved_because_the_driver_allows_it() + { + // MinBackoffMs = 0 means "retry immediately" and is the driver's own default — it must NOT be + // swept up by the positive-only rule that guards the four RequirePositive knobs. + var o = Emit(new MTConnectDriverForm.MTConnectFormModel + { + AgentUri = "http://agent:5000", + ReconnectMinBackoffMs = 0, + }); + + o["reconnect"]!["minBackoffMs"]!.GetValue().ShouldBe(0); + } + + // ---- Validate ------------------------------------------------------------------------------ + + [Fact] + public void Validate_rejects_a_blank_agent_uri() + => new MTConnectDriverForm.MTConnectFormModel { AgentUri = "" } + .Validate().ShouldNotBeNullOrEmpty(); + + [Fact] + public void Validate_rejects_a_non_positive_timing_knob() + => new MTConnectDriverForm.MTConnectFormModel { AgentUri = "http://agent:5000", SampleCount = 0 } + .Validate().ShouldNotBeNullOrEmpty(); + + [Fact] + public void Validate_ignores_probe_knobs_when_probing_is_off() + => new MTConnectDriverForm.MTConnectFormModel + { + AgentUri = "http://agent:5000", + ProbeEnabled = false, + ProbeIntervalMs = 0, + ProbeTimeoutMs = 0, + }.Validate().ShouldBeNull(); + + [Fact] + public void Validate_accepts_a_fully_populated_form() + => new MTConnectDriverForm.MTConnectFormModel { AgentUri = "http://agent:5000" } + .Validate().ShouldBeNull(); + + // ---- round trip ---------------------------------------------------------------------------- + + [Fact] + public void Round_trip_preserves_every_authored_value() + { + var original = new MTConnectDriverForm.MTConnectFormModel + { + AgentUri = "http://cell-a:5000", + DeviceName = "VMC-1", + RequestTimeoutMs = 7000, + SampleIntervalMs = 250, + SampleCount = 500, + HeartbeatMs = 15000, + ProbeEnabled = false, + ProbeIntervalMs = 9000, + ProbeTimeoutMs = 3000, + ReconnectMinBackoffMs = 100, + ReconnectMaxBackoffMs = 60000, + ReconnectBackoffMultiplier = 1.5, + }; + + var json = MTConnectDriverForm.BuildConfigJson(original, null); + var back = MTConnectDriverForm.MTConnectFormModel.FromJson(json); + + back.AgentUri.ShouldBe("http://cell-a:5000"); + back.DeviceName.ShouldBe("VMC-1"); + back.RequestTimeoutMs.ShouldBe(7000); + back.SampleIntervalMs.ShouldBe(250); + back.SampleCount.ShouldBe(500); + back.HeartbeatMs.ShouldBe(15000); + back.ProbeEnabled.ShouldBeFalse(); + back.ProbeIntervalMs.ShouldBe(9000); + back.ProbeTimeoutMs.ShouldBe(3000); + back.ReconnectMinBackoffMs.ShouldBe(100); + back.ReconnectMaxBackoffMs.ShouldBe(60000); + back.ReconnectBackoffMultiplier.ShouldBe(1.5); + } + + [Fact] + public void FromJson_reads_a_pascal_case_seeded_config() + { + // Seed SQL / hand-authored configs are PascalCase; the driver reads them case-insensitively, + // so the form must too or it silently shows defaults over a real config. + const string seeded = """ + { + "AgentUri": "http://10.100.0.35:5000", + "DeviceName": "Mill-01", + "RequestTimeoutMs": 8000, + "Probe": { "Enabled": false, "IntervalMs": 7000, "TimeoutMs": 4000 }, + "Reconnect": { "MinBackoffMs": 250, "MaxBackoffMs": 45000, "BackoffMultiplier": 3.0 } + } + """; + + var form = MTConnectDriverForm.MTConnectFormModel.FromJson(seeded); + + form.AgentUri.ShouldBe("http://10.100.0.35:5000"); + form.DeviceName.ShouldBe("Mill-01"); + form.RequestTimeoutMs.ShouldBe(8000); + form.ProbeEnabled.ShouldBeFalse(); + form.ProbeIntervalMs.ShouldBe(7000); + form.ProbeTimeoutMs.ShouldBe(4000); + form.ReconnectMinBackoffMs.ShouldBe(250); + form.ReconnectMaxBackoffMs.ShouldBe(45000); + form.ReconnectBackoffMultiplier.ShouldBe(3.0); + } + + [Fact] + public void FromJson_falls_back_to_defaults_for_an_unusable_document() + { + foreach (var bad in new[] { null, "", " ", "{", "\"scalar\"" }) + { + var form = MTConnectDriverForm.MTConnectFormModel.FromJson(bad); + form.RequestTimeoutMs.ShouldBe(5000); + form.SampleCount.ShouldBe(1000); + } + } + + [Fact] + public void Unknown_top_level_keys_survive_a_load_save() + { + // The driver also accepts a hand-authored tags[] surface; opening the form must not delete it. + const string existing = """{"agentUri":"http://old:5000","tags":[{"fullName":"x1"}]}"""; + + var form = MTConnectDriverForm.MTConnectFormModel.FromJson(existing); + form.AgentUri = "http://new:5000"; + + var o = Emit(form, existing); + o["agentUri"]!.GetValue().ShouldBe("http://new:5000"); + o["tags"]!.AsArray().Count.ShouldBe(1); + } + + [Fact] + public void A_previously_written_key_is_removed_when_its_value_becomes_non_positive() + { + const string existing = """{"agentUri":"http://a:5000","requestTimeoutMs":9000}"""; + + var form = MTConnectDriverForm.MTConnectFormModel.FromJson(existing); + form.RequestTimeoutMs = 0; + + // Not merely "not written" — the stale 9000 must not survive either, or the UI would show 0 + // while the stored config still said 9000. + Emit(form, existing).ContainsKey("requestTimeoutMs").ShouldBeFalse(); + } + + // ---- the shape the driver actually binds --------------------------------------------------- + + [Fact] + public void Emitted_config_binds_to_the_drivers_wire_dto_shape() + { + var json = MTConnectDriverForm.BuildConfigJson( + new MTConnectDriverForm.MTConnectFormModel + { + AgentUri = "http://agent:5000", + DeviceName = "VMC-1", + RequestTimeoutMs = 7000, + }, + null); + + // Mirror of MTConnectDriver.MTConnectDriverConfigDto (private there), asserting the emitted + // document binds field-for-field — a rename on either side breaks this. + var dto = JsonSerializer.Deserialize(json, _opts); + + dto.ShouldNotBeNull(); + dto!.AgentUri.ShouldBe("http://agent:5000"); + dto.DeviceName.ShouldBe("VMC-1"); + dto.RequestTimeoutMs.ShouldBe(7000); + dto.SampleIntervalMs.ShouldBe(1000); + dto.SampleCount.ShouldBe(1000); + dto.HeartbeatMs.ShouldBe(10000); + dto.Probe.ShouldNotBeNull(); + dto.Probe!.Enabled.ShouldBe(true); + dto.Probe.IntervalMs.ShouldBe(5000); + dto.Probe.TimeoutMs.ShouldBe(2000); + dto.Reconnect.ShouldNotBeNull(); + dto.Reconnect!.MinBackoffMs.ShouldBe(0); + dto.Reconnect.MaxBackoffMs.ShouldBe(30000); + dto.Reconnect.BackoffMultiplier.ShouldBe(2.0); + } + + private sealed class WireDto + { + 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 WireProbe? Probe { get; init; } + public WireReconnect? Reconnect { get; init; } + } + + private sealed class WireProbe + { + public bool? Enabled { get; init; } + public int? IntervalMs { get; init; } + public int? TimeoutMs { get; init; } + } + + private sealed class WireReconnect + { + public int? MinBackoffMs { get; init; } + public int? MaxBackoffMs { get; init; } + public double? BackoffMultiplier { get; init; } + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/MTConnectBrowseCommitRoundTripTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/MTConnectBrowseCommitRoundTripTests.cs new file mode 100644 index 00000000..2281fa3e --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/MTConnectBrowseCommitRoundTripTests.cs @@ -0,0 +1,102 @@ +using System.Text.Json.Nodes; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +/// +/// Pins the browse-picker → typed-editor round trip for MTConnect, the driver's primary +/// authoring path (a tag is committed from the universal browser, then confirmed/overridden in the +/// typed editor). +/// +/// +/// +/// The defect this guards: had no MTConnect +/// branch, so a committed tag landed as the generic {"address": "<dataItemId>"} +/// while read only fullName. The data plane kept +/// working (the driver accepts all three id spellings), so the ONLY symptom was an empty +/// DataItem-id field in the editor — and saving from that state stamped fullName: "" +/// over the tag. Razor is exactly what this repo cannot unit-test, so both halves of the fix +/// are pinned here in plain C#. +/// +/// +/// Both halves are asserted: the mapper now writes the canonical fullName (fixes new +/// commits), and the model accepts dataItemId/address as fallbacks (fixes tags +/// already committed before the fix). +/// +/// +[Trait("Category", "Unit")] +public sealed class MTConnectBrowseCommitRoundTripTests +{ + [Fact] + public void BuildTagConfig_writes_the_canonical_fullName_key_for_MTConnect() + { + var json = RawBrowseCommitMapper.BuildTagConfig(DriverTypeNames.MTConnect, "avail"); + + var o = JsonNode.Parse(json)!.AsObject(); + o["fullName"]!.GetValue().ShouldBe("avail"); + o.ContainsKey("address").ShouldBeFalse( + "the generic fallback key must not be used now that MTConnect has a typed editor"); + } + + [Fact] + public void A_browse_committed_tag_round_trips_through_the_typed_editor_model() + { + var committed = RawBrowseCommitMapper.BuildTagConfig(DriverTypeNames.MTConnect, "Xact"); + + var model = MTConnectTagConfigModel.FromJson(committed); + + model.FullName.ShouldBe("Xact"); + model.Validate().ShouldBeNull("a browse-committed tag must open in the editor already valid"); + MTConnectTagConfigModel.FromJson(model.ToJson()).FullName.ShouldBe("Xact"); + } + + // ---- legacy blobs committed before the mapper fix ------------------------------------------- + + [Theory] + [InlineData("address")] + [InlineData("dataItemId")] + public void FromJson_accepts_the_drivers_alternate_id_spellings(string key) + { + var model = MTConnectTagConfigModel.FromJson($$"""{"{{key}}":"Ypos"}"""); + + model.FullName.ShouldBe("Ypos"); + model.Validate().ShouldBeNull(); + } + + [Fact] + public void FullName_wins_over_dataItemId_which_wins_over_address() + { + MTConnectTagConfigModel + .FromJson("""{"fullName":"a","dataItemId":"b","address":"c"}""") + .FullName.ShouldBe("a"); + + MTConnectTagConfigModel + .FromJson("""{"dataItemId":"b","address":"c"}""") + .FullName.ShouldBe("b"); + } + + [Fact] + public void A_blank_alias_does_not_shadow_a_populated_one() + => MTConnectTagConfigModel + .FromJson("""{"fullName":" ","address":"c"}""") + .FullName.ShouldBe("c"); + + [Fact] + public void Saving_a_legacy_blob_normalises_it_onto_fullName_and_drops_the_aliases() + { + var model = MTConnectTagConfigModel.FromJson("""{"address":"Zpos","units":"MILLIMETER"}"""); + + var o = JsonNode.Parse(model.ToJson())!.AsObject(); + + o["fullName"]!.GetValue().ShouldBe("Zpos"); + o.ContainsKey("address").ShouldBeFalse( + "leaving a second id spelling behind lets the two drift apart on the next edit"); + o.ContainsKey("dataItemId").ShouldBeFalse(); + // Unrelated keys are still preserved — normalisation is not a purge. + o["units"]!.GetValue().ShouldBe("MILLIMETER"); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/MTConnectTagConfigModelTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/MTConnectTagConfigModelTests.cs new file mode 100644 index 00000000..81aff22e --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/MTConnectTagConfigModelTests.cs @@ -0,0 +1,179 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns.TagEditors; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +public sealed class MTConnectTagConfigModelTests +{ + [Fact] + public void Roundtrip_preserves_unknown_keys_and_writes_enum_as_name() + { + var json = "{\"fullName\":\"dev1_pos\",\"dataType\":\"Float64\",\"somethingUnknown\":42}"; + var m = MTConnectTagConfigModel.FromJson(json); + var outJson = m.ToJson(); + outJson.ShouldContain("\"somethingUnknown\":42"); + outJson.ShouldContain("\"dataType\":\"Float64\""); // name, never a number + } + + [Fact] + public void Validate_blocks_blank_fullname() + => MTConnectTagConfigModel.FromJson("{}").Validate().ShouldNotBeNull(); + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("{}")] + public void FromJson_returns_defaults_for_empty_input(string? json) + { + var m = MTConnectTagConfigModel.FromJson(json); + + m.FullName.ShouldBe(""); + m.DataType.ShouldBe(DriverDataType.String); + m.MtCategory.ShouldBeNull(); + m.MtType.ShouldBeNull(); + m.MtSubType.ShouldBeNull(); + m.Units.ShouldBeNull(); + m.MtDevice.ShouldBeNull(); + m.MtComponent.ShouldBeNull(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Validate_blocks_missing_and_whitespace_fullname(string? fullName) + { + var m = new MTConnectTagConfigModel { FullName = fullName! }; + m.Validate().ShouldNotBeNull(); + } + + [Fact] + public void Validate_returns_null_for_a_valid_model() + { + var m = new MTConnectTagConfigModel { FullName = "dev1_pos", DataType = DriverDataType.Float64 }; + m.Validate().ShouldBeNull(); + } + + [Fact] + public void ToJson_emits_camelCase_keys_with_enum_name_and_all_fields() + { + var m = new MTConnectTagConfigModel + { + FullName = "dev1_partcount", + DataType = DriverDataType.Int64, + MtCategory = "EVENT", + MtType = "PART_COUNT", + MtSubType = "ACTUAL", + Units = "COUNT", + MtDevice = "Mill-01", + MtComponent = "Controller", + }; + + var json = m.ToJson(); + + json.ShouldContain("\"fullName\":\"dev1_partcount\""); + json.ShouldContain("\"dataType\":\"Int64\""); + json.ShouldContain("\"mtCategory\":\"EVENT\""); + json.ShouldContain("\"mtType\":\"PART_COUNT\""); + json.ShouldContain("\"mtSubType\":\"ACTUAL\""); + json.ShouldContain("\"units\":\"COUNT\""); + json.ShouldContain("\"mtDevice\":\"Mill-01\""); + json.ShouldContain("\"mtComponent\":\"Controller\""); + } + + [Fact] + public void ToJson_never_emits_a_bare_numeric_dataType() + { + foreach (DriverDataType value in Enum.GetValues()) + { + var json = new MTConnectTagConfigModel { FullName = "x", DataType = value }.ToJson(); + + // The enum-serialization trap: "dataType":8 would fault the driver at deploy (string-typed + // factory DTOs). Assert the value after the key is a quoted name, not a bare digit. + json.ShouldNotContain($"\"dataType\":{(int)value}"); + json.ShouldContain($"\"dataType\":\"{value}\""); + } + } + + [Fact] + public void FromJson_then_ToJson_preserves_unknown_keys_including_nested_object_and_isHistorized() + { + var json = """ + { + "fullName": "dev1_pos", + "dataType": "Float64", + "isHistorized": true, + "historianTagname": "Mill01.Position", + "nested": { "a": 1, "b": [1, 2, 3] } + } + """; + + var roundTripped = MTConnectTagConfigModel.FromJson(json).ToJson(); + + roundTripped.ShouldContain("\"isHistorized\":true"); + roundTripped.ShouldContain("\"historianTagname\":\"Mill01.Position\""); + roundTripped.ShouldContain("\"nested\":{\"a\":1,\"b\":[1,2,3]}"); + // and the modelled fields still round-trip alongside the preserved keys + roundTripped.ShouldContain("\"fullName\":\"dev1_pos\""); + roundTripped.ShouldContain("\"dataType\":\"Float64\""); + } + + [Fact] + public void FromJson_of_malformed_json_falls_back_to_defaults_rather_than_throwing() + { + var m = MTConnectTagConfigModel.FromJson("{not valid json"); + + m.FullName.ShouldBe(""); + m.DataType.ShouldBe(DriverDataType.String); + } + + [Fact] + public void Round_trip_is_stable_for_a_fully_populated_model() + { + var m = new MTConnectTagConfigModel + { + FullName = "dev1_feedrate", + DataType = DriverDataType.Float64, + MtCategory = "SAMPLE", + MtType = "PATH_FEEDRATE", + MtSubType = "PROGRAMMED", + Units = "MILLIMETER/SECOND", + MtDevice = "Mill-01", + MtComponent = "Path", + }; + + var m2 = MTConnectTagConfigModel.FromJson(m.ToJson()); + + m2.FullName.ShouldBe(m.FullName); + m2.DataType.ShouldBe(m.DataType); + m2.MtCategory.ShouldBe(m.MtCategory); + m2.MtType.ShouldBe(m.MtType); + m2.MtSubType.ShouldBe(m.MtSubType); + m2.Units.ShouldBe(m.Units); + m2.MtDevice.ShouldBe(m.MtDevice); + m2.MtComponent.ShouldBe(m.MtComponent); + } + + [Fact] + public void Optional_metadata_keys_are_omitted_when_blank_rather_than_persisted_empty() + { + var json = new MTConnectTagConfigModel { FullName = "dev1_pos", MtCategory = " " }.ToJson(); + + json.ShouldNotContain("mtCategory"); + } + + [Fact] + public void Validate_rejects_a_dataType_that_was_stored_as_a_bare_number() + { + // Enum.TryParse (which TagConfigJson.GetEnum uses under the hood) silently accepts numeric text, + // so a config that was ever hand-authored/migrated with a quoted-number dataType would otherwise + // load into a plausible-looking enum value with no signal. Guard it explicitly. + var m = MTConnectTagConfigModel.FromJson("{\"fullName\":\"dev1_pos\",\"dataType\":\"8\"}"); + + m.DataType.ShouldBe(DriverDataType.Float64); // 8 == DriverDataType.Float64 — parsed "successfully" + m.Validate().ShouldNotBeNull(); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawDriverTypeDialogCoverageTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawDriverTypeDialogCoverageTests.cs new file mode 100644 index 00000000..9610d22d --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawDriverTypeDialogCoverageTests.cs @@ -0,0 +1,59 @@ +using System.Reflection; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Raw; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +/// +/// Coverage guard for the /raw "New driver" dialog. is the +/// ONLY surface that creates a DriverInstance, and its offered types are a hand-authored array +/// — so a driver whose factory is registered but whose entry was never added here is +/// un-creatable in the AdminUI with nothing failing anywhere (the MTConnect gap Task 16 found). +/// This test resolves through , so a newly registered driver type +/// fails here until the dialog offers it. +/// +/// +/// There is no bUnit in this project, so the dialog's markup cannot be rendered. The offered set is +/// read by reflection off the private static Types table the markup enumerates — the same +/// substitute-for-a-component-test approach DriverPageJsonConverterTests uses. +/// +public sealed class RawDriverTypeDialogCoverageTests +{ + private static IReadOnlyList OfferedDriverTypes() + { + var field = typeof(RawDriverTypeDialog) + .GetField("Types", BindingFlags.NonPublic | BindingFlags.Static); + field.ShouldNotBeNull("RawDriverTypeDialog must expose its offered driver types as a static 'Types' table."); + + var table = (ValueTuple[])field!.GetValue(null)!; + return [.. table.Select(t => t.Item2)]; + } + + [Theory] + [MemberData(nameof(AllDriverTypes))] + public void New_driver_dialog_offers_every_registered_driver_type(string driverType) + => OfferedDriverTypes().ShouldContain( + driverType, + $"RawDriverTypeDialog must offer DriverTypeNames.{driverType} — otherwise a driver of that " + + "type can never be created in /raw, which is the only creation surface."); + + /// xUnit theory source over every registered driver-type constant. + public static TheoryData AllDriverTypes() + { + var data = new TheoryData(); + foreach (var t in DriverTypeNames.All) + data.Add(t); + return data; + } + + [Fact] + public void Offered_driver_types_are_canonical_constants_and_unique() + { + var offered = OfferedDriverTypes(); + offered.Distinct(StringComparer.Ordinal).Count().ShouldBe(offered.Count, "duplicate driver-type entries"); + foreach (var t in offered) + DriverTypeNames.All.ShouldContain(t, $"'{t}' is not a canonical DriverTypeNames constant."); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigDriverTypeNameGuardTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigDriverTypeNameGuardTests.cs index 60c9c318..cc581114 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigDriverTypeNameGuardTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigDriverTypeNameGuardTests.cs @@ -27,6 +27,7 @@ public sealed class TagConfigDriverTypeNameGuardTests DriverTypeNames.FOCAS, DriverTypeNames.OpcUaClient, DriverTypeNames.Calculation, + DriverTypeNames.MTConnect, ]; [Theory] @@ -46,6 +47,7 @@ public sealed class TagConfigDriverTypeNameGuardTests DriverTypeNames.TwinCAT, DriverTypeNames.FOCAS, DriverTypeNames.OpcUaClient, + DriverTypeNames.MTConnect, ]; [Theory] diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeRegistrationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeRegistrationTests.cs index c2e74697..61d4a148 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeRegistrationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DriverProbeRegistrationTests.cs @@ -32,6 +32,7 @@ public sealed class DriverProbeRegistrationTests "OpcUaClient", "GalaxyMxGateway", "Calculation", // v3 Batch 2 pseudo-driver; CalculationProbe registered in DriverFactoryBootstrap + "MTConnect", // MTConnectProbe — read-only Agent driver; probe issues a bare GET /probe ]; [Fact]