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