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