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,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)}";
}
}