using System.Globalization; using System.Xml; using System.Xml.Linq; namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect; /// /// The element/attribute reading rules shared by and /// . Both documents are the same dialect of XML and must be /// read by the same rules; keeping one copy is what stops the two parsers drifting apart on the /// two rules below, either of which fails silently rather than loudly. /// /// /// /// Rule 1: elements are matched on , ignoring the /// namespace. The namespace URI carries the MTConnect version /// (urn:mtconnect.org:MTConnectDevices:1.3 … :2.0), so a fully-qualified match /// would hard-code a version. It also silently drops vendor-extension elements declared in /// their own namespace, whose standard children still inherit the default MTConnect /// namespace — the browse tree simply comes back short, with no error anywhere. /// /// /// Rule 2: attributes are read unqualified. A prefixed attribute /// (xsi:type on a document root, a vendor's x:dataItemId) must never be /// mistaken for the real type / dataItemId, which would invent a data item the /// probe never declared. /// /// internal static class MTConnectXml { /// Does have the given local name, whatever its namespace? /// The element to test. /// The unqualified name to match. public static bool IsNamed(XElement element, string localName) => string.Equals(element.Name.LocalName, localName, StringComparison.Ordinal); /// /// Does the element the reader is positioned on have the given local name, whatever its /// namespace? /// /// /// reads its documents through a forward-only /// rather than a materialised tree — one sample chunk is read once /// and thrown away, so building an for it is pure waste. The reader /// overloads here exist so that going forward-only did not fork the two silent rules in the /// type-level remarks: both flavours match on the local name, read attributes unqualified, /// and produce the same error text. /// /// A reader positioned on an element. /// The unqualified name to match. public static bool IsNamed(XmlReader reader, string localName) => string.Equals(reader.LocalName, localName, StringComparison.Ordinal); /// Direct element children of with the given local name. /// The element whose children to filter. /// The unqualified name to match. public static IEnumerable ChildrenNamed(XElement parent, string localName) => parent.Elements().Where(child => IsNamed(child, localName)); /// /// Reads an unqualified attribute, normalizing a present-but-empty value to null. /// See the type-level remarks for why only unqualified attributes are considered. /// /// The element carrying the attribute. /// The unqualified attribute name. public static string? OptionalAttribute(XElement element, string name) { var value = element.Attribute(name)?.Value; return string.IsNullOrWhiteSpace(value) ? null : value; } /// /// Reads an unqualified attribute off the element the reader is positioned on, normalizing a /// present-but-empty value to null. See for /// why the reader overloads exist. /// /// /// matches on the attribute's qualified /// name, so a prefixed x:dataItemId does not answer to dataItemId — exactly /// what rule 2 in the type-level remarks requires, and what /// does for a namespace-free . /// /// A reader positioned on an element. /// The unqualified attribute name. public static string? OptionalAttribute(XmlReader reader, string name) { var value = reader.GetAttribute(name); return string.IsNullOrWhiteSpace(value) ? null : value; } /// Reads a required unqualified attribute, or throws naming the owner and the attribute. /// The element carrying the attribute. /// The unqualified attribute name. /// How to describe the element in the error message. /// How to describe the document in the error message (e.g. "/probe response"). /// The attribute is absent or blank. public static string RequiredAttribute(XElement element, string name, string owner, string subject) => OptionalAttribute(element, name) ?? throw MissingAttribute(name, owner, subject); /// /// Reads a required unqualified attribute off the element the reader is positioned on, or /// throws naming the owner and the attribute. /// /// A reader positioned on an element. /// The unqualified attribute name. /// How to describe the element in the error message. /// How to describe the document in the error message. /// The attribute is absent or blank. public static string RequiredAttribute(XmlReader reader, string name, string owner, string subject) => OptionalAttribute(reader, name) ?? throw MissingAttribute(name, owner, subject); /// Reads an optional unqualified attribute as an . /// The element carrying the attribute. /// The unqualified attribute name. /// How to describe the element in the error message. /// How to describe the document in the error message. /// The attribute is present but not an integer. public static int? OptionalIntAttribute(XElement element, string name, string owner, string subject) { 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 {subject} is malformed: {owner} has a non-numeric '{name}' attribute ('{raw}')."); } return value; } /// Reads a required unqualified attribute as a . /// The element carrying the attribute. /// The unqualified attribute name. /// How to describe the element in the error message. /// How to describe the document in the error message. /// The attribute is absent, blank, or not an integer. public static long RequiredLongAttribute(XElement element, string name, string owner, string subject) => AsLong(RequiredAttribute(element, name, owner, subject), name, owner, subject); /// /// Reads a required unqualified attribute off the element the reader is positioned on, as a /// . /// /// A reader positioned on an element. /// The unqualified attribute name. /// How to describe the element in the error message. /// How to describe the document in the error message. /// The attribute is absent, blank, or not an integer. public static long RequiredLongAttribute(XmlReader reader, string name, string owner, string subject) => AsLong(RequiredAttribute(reader, name, owner, subject), name, owner, subject); /// The one spelling of "that attribute is missing", shared by both flavours. private static InvalidDataException MissingAttribute(string name, string owner, string subject) => new($"MTConnect {subject} is malformed: {owner} has no '{name}' attribute."); /// The one spelling of "that attribute is not a number", shared by both flavours. private static long AsLong(string raw, string name, string owner, string subject) { if (!long.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out var value)) { throw new InvalidDataException( $"MTConnect {subject} is malformed: {owner} has a non-numeric '{name}' attribute ('{raw}')."); } return value; } /// /// Builds the error message for a document whose root is not what was expected, lifting the /// Agent's own error text when the response is an MTConnectError document — which /// Agents return under HTTP 200, so EnsureSuccessStatusCode never fires and /// this is the only place the operator can learn what the Agent objected to. /// /// The document's actual root element. /// The root element name the caller required. /// How to describe the document in the error message. public static string DescribeUnexpectedRoot(XElement root, string expectedRootName, string subject) { var message = $"MTConnect {subject} root element is <{root.Name.LocalName}>, expected <{expectedRootName}>."; if (!IsNamed(root, "MTConnectError")) { return message; } var errors = ExtractErrorText(root); return errors is null ? message : $"{message} The Agent reported: {errors}"; } /// /// The Agent's own words from an MTConnectError document: every <Error> /// beneath , rendered errorCode: text where a code is present /// and joined with "; ". null when the document carries no readable error /// detail at all (no <Error> elements, or only blank ones). /// /// /// /// Shared because both callers must read the same document the same way. An /// Agent's objection reaches this driver through two unrelated doors — a read path that /// got an error document where it expected a model /// (), and the write path deciding whether a 200 /// actually accepted the observation. Two copies of this walk would drift on the same /// two silent rules the type-level remarks describe, and on the write side a missed /// <Error> does not merely degrade a message: it reads as a successful /// write. /// /// /// null means "no readable detail", NOT "not an error document". Only the /// caller knows which document it is holding — check the root name first. A caller that /// treats a null here as "no error occurred" turns a hollow /// <MTConnectError/> into a success. /// /// /// The MTConnectError document's root element. public static string? ExtractErrorText(XElement root) { ArgumentNullException.ThrowIfNull(root); 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 ? null : string.Join("; ", errors); } }