feat(mtconnect): agent client /probe parse to device model (Task 6)

This commit is contained in:
Joseph Doherty
2026-07-24 14:08:54 -04:00
parent b13cf5c6e8
commit 1990d21733
3 changed files with 870 additions and 0 deletions
@@ -0,0 +1,142 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <summary>
/// The production <see cref="IMTConnectAgentClient"/> — a thin <see cref="HttpClient"/> wrapper
/// over an MTConnect Agent's REST surface that hands every response body to a pure parser and
/// returns only the neutral DTOs in <c>MTConnectDtos.cs</c>.
/// </summary>
/// <remarks>
/// <para>
/// <b>The constructor opens no connection.</b> It only composes the request URIs and builds
/// an <see cref="HttpClient"/> (which itself dials nothing until a request is issued), so a
/// throwaway instance is safe to construct — the universal discovery browser's
/// <c>CanBrowse</c> pattern depends on that.
/// </para>
/// <para>
/// <b>Every call is deadline-bounded.</b> Each request runs under a
/// <see cref="CancellationTokenSource"/> linked to the caller's token and cancelled after
/// <see cref="MTConnectDriverOptions.RequestTimeoutMs"/>, <i>and</i>
/// <see cref="HttpClient.Timeout"/> is set to the same bound. A frozen Agent surfaces as a
/// <see cref="TimeoutException"/> rather than wedging the caller — the lesson from this
/// repo's S7 read-leg wall-clock gap (arch-review R2-01). A non-positive timeout is rejected
/// at construction so an operator-authored <c>0</c> can never come to mean "wait forever"
/// (arch-review 01/S-6).
/// </para>
/// <para>
/// This class does not swallow failures into an empty model: an unreachable Agent, a non-2xx
/// status, or an unparseable body all throw. <c>InitializeAsync</c> (Task 9) is what catches
/// that and moves the driver to Faulted.
/// </para>
/// </remarks>
public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
{
private readonly HttpClient _http;
private readonly Uri _probeUri;
private readonly TimeSpan _requestTimeout;
/// <summary>Creates a client for the Agent described by <paramref name="options"/>.</summary>
/// <param name="options">The driver options carrying the Agent URI, device scope, and per-call deadline.</param>
/// <exception cref="ArgumentException"><see cref="MTConnectDriverOptions.AgentUri"/> is missing or is not an absolute HTTP(S) URI.</exception>
/// <exception cref="ArgumentOutOfRangeException"><see cref="MTConnectDriverOptions.RequestTimeoutMs"/> is not positive.</exception>
public MTConnectAgentClient(MTConnectDriverOptions options)
: this(options, handler: null)
{
}
/// <summary>
/// Test seam: as <see cref="MTConnectAgentClient(MTConnectDriverOptions)"/>, but sends through
/// the supplied handler so the request/response round trip can be exercised with no socket.
/// The caller retains ownership of <paramref name="handler"/>.
/// </summary>
internal MTConnectAgentClient(MTConnectDriverOptions options, HttpMessageHandler? handler)
{
ArgumentNullException.ThrowIfNull(options);
if (options.RequestTimeoutMs <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(options),
options.RequestTimeoutMs,
$"{nameof(MTConnectDriverOptions.RequestTimeoutMs)} must be positive; a non-positive per-call deadline would let a frozen Agent wedge the driver indefinitely.");
}
_requestTimeout = TimeSpan.FromMilliseconds(options.RequestTimeoutMs);
_probeUri = BuildRequestUri(options, "probe");
_http = handler is null ? new HttpClient() : new HttpClient(handler, disposeHandler: false);
_http.Timeout = _requestTimeout;
}
/// <inheritdoc/>
public async Task<MTConnectProbeModel> ProbeAsync(CancellationToken ct)
{
// 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);
}
/// <inheritdoc/>
public Task<MTConnectStreamsResult> CurrentAsync(CancellationToken ct) =>
// Task 7 adds the /current leg (MTConnectStreamsParser); deliberately not stubbed as an
// empty result, which would read as a working call that silently returns nothing.
throw new NotImplementedException("The MTConnect /current leg lands in Task 7.");
/// <inheritdoc/>
public IAsyncEnumerable<MTConnectStreamsResult> SampleAsync(long from, CancellationToken ct) =>
// Task 7 adds the /sample long-poll leg (multipart chunk framing + sequence-gap detection).
throw new NotImplementedException("The MTConnect /sample leg lands in Task 7.");
/// <inheritdoc/>
public void Dispose() => _http.Dispose();
private async Task<HttpResponseMessage> SendAsync(Uri uri, CancellationToken ct)
{
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(ct);
deadline.CancelAfter(_requestTimeout);
HttpResponseMessage response;
try
{
response = await _http.GetAsync(uri, deadline.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
// The caller did not cancel, 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 new TimeoutException(
$"MTConnect Agent request to '{uri}' did not complete within {_requestTimeout.TotalMilliseconds:F0} ms.");
}
response.EnsureSuccessStatusCode();
return response;
}
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);
}
}
@@ -0,0 +1,254 @@
using System.Globalization;
using System.Xml;
using System.Xml.Linq;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <summary>
/// Turns an MTConnect Agent <c>/probe</c> response (an <c>MTConnectDevices</c> XML document)
/// into the neutral <see cref="MTConnectProbeModel"/>. Deliberately a pure, socket-free static
/// so the whole parse is unit-testable against canned fixtures.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why hand-rolled rather than TrakHound.</b> The referenced TrakHound packages
/// (<c>MTConnect.NET-Common</c> + <c>-HTTP</c> 6.9.0.2) contain <b>no</b>
/// <c>IResponseDocumentFormatter</c> implementation — the XML formatter ships in the
/// separate, unreferenced <c>MTConnect.NET-XML</c> package — so
/// <c>ResponseDocumentFormatter.CreateDevicesResponseDocument("xml", stream)</c> answers
/// <c>Success = false</c> / "Document Formatter Not found for &quot;xml&quot;" (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.
/// </para>
/// <para>
/// <b>Three shapes of the wire format this parser is built around.</b>
/// (1) A device may declare data items <i>directly</i> on <c>&lt;Device&gt;&lt;DataItems&gt;</c>,
/// outside any component — a walker that only recurses <c>&lt;Components&gt;</c> silently
/// drops them.
/// (2) The children of <c>&lt;Components&gt;</c> are named for the component <i>type</i>
/// (<c>&lt;Controller&gt;</c>, <c>&lt;Path&gt;</c>, <c>&lt;Axes&gt;</c>, …) — there is no
/// literal <c>&lt;Component&gt;</c> element — so <i>every</i> element child of
/// <c>&lt;Components&gt;</c> is a component.
/// (3) The document is XML-namespaced and the namespace URI carries the MTConnect version
/// (<c>urn:mtconnect.org:MTConnectDevices:1.3</c> … <c>:2.0</c>). Elements are therefore
/// matched on <see cref="XName.LocalName"/> and the namespace is ignored entirely — nothing
/// version-specific is hard-coded, and a vendor-extension component in its own namespace
/// (whose standard child elements still inherit the default MTConnect namespace) is not
/// silently dropped. Attributes, by contrast, are read unqualified so a prefixed attribute
/// (e.g. <c>xsi:type</c>) can never be mistaken for a DataItem's <c>type</c>.
/// </para>
/// <para>
/// <b>Failure posture.</b> Anything that is not a well-formed MTConnect device model throws
/// <see cref="InvalidDataException"/>. 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.
/// </para>
/// </remarks>
internal static class MTConnectProbeParser
{
/// <summary>
/// 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.
/// </summary>
private const int MaxComponentDepth = 64;
/// <summary>Parses a <c>/probe</c> response held as a string.</summary>
/// <param name="xml">The raw <c>MTConnectDevices</c> XML document.</param>
/// <exception cref="InvalidDataException">
/// The payload is empty, not well-formed XML, not an <c>MTConnectDevices</c> document (an
/// Agent <c>MTConnectError</c> document included — Agents answer a bad device path with one
/// under HTTP 200), or declares no usable device.
/// </exception>
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);
}
/// <summary>Parses a <c>/probe</c> response read from a stream.</summary>
/// <param name="stream">A stream positioned at the start of the XML document.</param>
/// <exception cref="InvalidDataException">As for <see cref="Parse(string)"/>.</exception>
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 (!IsNamed(root, "MTConnectDevices"))
{
throw new InvalidDataException(DescribeUnexpectedRoot(root));
}
var devicesContainer = ChildrenNamed(root, "Devices").FirstOrDefault()
?? throw new InvalidDataException(
"MTConnect /probe response has no <Devices> 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(
RequiredAttribute(element, "id", "Device"),
OptionalAttribute(element, "name"),
ReadComponents(element, depth: 1),
ReadDataItems(element));
private static MTConnectComponent ReadComponent(XElement element, int depth) =>
new(
RequiredAttribute(element, "id", $"Component <{element.Name.LocalName}>"),
OptionalAttribute(element, "name"),
ReadComponents(element, depth + 1),
ReadDataItems(element));
/// <summary>
/// Every element child of this container's <c>&lt;Components&gt;</c> element, whatever it is
/// named — the element name is the component type, not the literal string "Component".
/// </summary>
private static IReadOnlyList<MTConnectComponent> 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 ChildrenNamed(container, "Components")
.SelectMany(components => components.Elements())
.Select(element => ReadComponent(element, depth))
.ToList();
}
private static IReadOnlyList<MTConnectDataItem> ReadDataItems(XElement container) =>
ChildrenNamed(container, "DataItems")
.SelectMany(dataItems => ChildrenNamed(dataItems, "DataItem"))
.Select(ReadDataItem)
.ToList();
private static MTConnectDataItem ReadDataItem(XElement element)
{
var id = RequiredAttribute(element, "id", "DataItem");
return new MTConnectDataItem(
id,
OptionalAttribute(element, "name"),
RequiredAttribute(element, "category", $"DataItem '{id}'"),
RequiredAttribute(element, "type", $"DataItem '{id}'"),
OptionalAttribute(element, "subType"),
OptionalAttribute(element, "units"),
OptionalAttribute(element, "representation"),
OptionalIntAttribute(element, "sampleCount", id));
}
private static bool IsNamed(XElement element, string localName) =>
string.Equals(element.Name.LocalName, localName, StringComparison.Ordinal);
private static IEnumerable<XElement> ChildrenNamed(XElement parent, string localName) =>
parent.Elements().Where(child => IsNamed(child, localName));
private static string RequiredAttribute(XElement element, string name, string owner)
{
var value = OptionalAttribute(element, name);
return value ?? throw new InvalidDataException(
$"MTConnect /probe response is malformed: {owner} has no '{name}' attribute.");
}
/// <summary>
/// Reads an unqualified attribute, normalizing a present-but-empty value to <c>null</c>.
/// Only unqualified attributes are considered, so a namespace-prefixed attribute such as
/// <c>xsi:type</c> can never be read as a DataItem's <c>type</c>.
/// </summary>
private static string? OptionalAttribute(XElement element, string name)
{
var value = element.Attribute(name)?.Value;
return string.IsNullOrWhiteSpace(value) ? null : value;
}
private static int? OptionalIntAttribute(XElement element, string name, string dataItemId)
{
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 /probe response is malformed: DataItem '{dataItemId}' has a non-numeric '{name}' attribute ('{raw}').");
}
return value;
}
/// <summary>
/// Builds the message for a root element that is not <c>MTConnectDevices</c>, lifting the
/// Agent's own error text when the response is an <c>MTConnectError</c> document (which
/// Agents return under HTTP 200 for, e.g., an unknown device name).
/// </summary>
private static string DescribeUnexpectedRoot(XElement root)
{
var message =
$"MTConnect /probe response root element is <{root.Name.LocalName}>, expected <MTConnectDevices>.";
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)}";
}
}