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