feat(mtconnect): agent client /probe parse to device model (Task 6)
This commit is contained in:
@@ -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 "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.
|
||||
/// </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><Device><DataItems></c>,
|
||||
/// outside any component — a walker that only recurses <c><Components></c> silently
|
||||
/// drops them.
|
||||
/// (2) The children of <c><Components></c> are named for the component <i>type</i>
|
||||
/// (<c><Controller></c>, <c><Path></c>, <c><Axes></c>, …) — there is no
|
||||
/// literal <c><Component></c> element — so <i>every</i> element child of
|
||||
/// <c><Components></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><Components></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)}";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user