using System.Globalization;
using System.Runtime.InteropServices;
using System.Text;
using System.Xml;
using System.Xml.Linq;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
///
/// Turns an MTConnect Agent MTConnectStreams document — a /current snapshot or one
/// chunk of a /sample multipart stream — into the neutral
/// . Deliberately a pure, socket-free static so the whole
/// parse is unit-testable against canned fixtures; chunk framing lives in
/// and hands this parser one chunk's XML at a time.
///
///
///
/// Why hand-rolled rather than TrakHound. Same finding as
/// : the packages pinned by Task 0 ship no XML document
/// formatter, and TrakHound's streaming client offers no socket-free entry point. Both
/// package references were dropped in Task 7; the driver depends only on the BCL.
///
///
/// The Streams document is shaped nothing like the Devices document, and three
/// differences drive this parser's design.
///
///
/// -
///
/// The observation element's name is the data item's type in PascalCase
/// (Position, PartCount, Execution) — not the probe's
/// UPPER_SNAKE POSITION / PART_COUNT. The standard defines hundreds of
/// types and vendors add more, so matching a fixed set of element names would drop
/// observations silently. Every element child of <Samples>,
/// <Events> and <Condition> is therefore an observation,
/// keyed by its dataItemId attribute — the only identity the probe model and
/// the streams document actually share.
///
///
/// -
///
/// A CONDITION observation's value is its ELEMENT NAME, not its text.
/// <Normal/> / <Warning/> / <Fault/> /
/// <Unavailable/> carry the state in the name and are typically empty;
/// where text is present it is the operator-facing message, not the state. Reading
/// the text would report every condition as an empty string.
/// <Unavailable/> is normalized to the literal
/// so a no-comms condition lands on exactly the
/// same sentinel as a Sample/Event whose text is UNAVAILABLE — the
/// observation index maps that one token to bad quality.
///
///
/// -
///
/// Timestamps are normalized to UTC explicitly. MTConnect timestamps are
/// ISO-8601 Zulu, but a bare DateTime.Parse yields Local or
/// Unspecified depending on machine settings — and this value becomes the
/// OPC UA SourceTimestamp, so a wrong shifts every
/// timestamp by the host's UTC offset with nothing to show for it.
///
///
///
///
/// The read is forward-only. A /sample chunk is parsed once and thrown away,
/// so it is read straight off an rather than through an
/// : on the pump's hot path the tree was pure garbage — one
/// XElement plus its attributes and text nodes per observation, per chunk, per
/// second — and nothing here needs a second look at a node it has already passed. The
/// extraction is unchanged; the two rules that keep it honest are that whitespace-only text
/// nodes are dropped exactly as dropped them (see
/// ), and that an observation's slot in the result list is
/// reserved before its subtree is read so observations still arrive in document
/// order. The one place a tree is still built is the cold non-MTConnectStreams root
/// below, where the Agent's own error text has to be lifted out of a document this parser
/// is otherwise finished with.
///
///
/// One thing did change, and only for a document that is wrong twice over. A missing
/// <Header> used to be reported ahead of a malformed observation, because the
/// tree could be interrogated in whatever order suited; forward-only, the header is not
/// known to be missing until the root's children have run out, so the observation is
/// reported first. Both are , and nothing that is wrong in
/// only one way reports differently than it did.
///
///
/// Failure posture. A malformed document, a non-MTConnectStreams root (an Agent
/// MTConnectError served under HTTP 200 included), an unusable header, or an
/// observation missing its dataItemId/timestamp all throw
/// . A document with a valid header and zero
/// observations, by contrast, is entirely legal — /sample chunks are deltas and an
/// idle Agent's keep-alive is an observation-free document — so it parses to an empty
/// observation list rather than throwing.
///
///
internal static class MTConnectStreamsParser
{
///
/// The single token that means "the Agent has no value for this data item". Sample/Event
/// observations carry it as text; a CONDITION carries it as the element name
/// <Unavailable/> and is normalized onto this spelling here.
///
private const string UnavailableSentinel = "UNAVAILABLE";
private const string ConditionContainer = "Condition";
/// How 's shared error messages name this document.
private const string Subject = "streams response";
/// How those messages name the header element.
private const string HeaderOwner = "the ";
///
/// How the whole document is read. Two settings are load-bearing rather than incidental.
///
///
///
/// reproduces
/// 's default. XDocument.Load/Parse without
/// sets exactly this, so a whitespace-only
/// text node never became an XText and never reached XElement.Value.
/// Leaving it at the reader's own default would silently start folding indentation into
/// observation values that contain element children — "1 2" where the tree read
/// "12".
///
///
/// A DTD is prohibited because a streams document is untrusted network input and
/// entity expansion is not a feature this parser has any use for. This too matches what
/// defaulted to.
///
///
private static readonly XmlReaderSettings ReaderSettings = new()
{
IgnoreWhitespace = true,
DtdProcessing = DtdProcessing.Prohibit,
// The caller owns the stream it handed in; disposing the reader must not close it.
CloseInput = false
};
/// Parses a /current response, or one /sample chunk, held as a string.
/// The raw MTConnectStreams XML document.
///
/// The payload is empty, not well-formed XML, not an MTConnectStreams document (an
/// Agent MTConnectError included — Agents answer a bad request with one under HTTP
/// 200), carries no usable <Header>, or carries a malformed observation.
///
public static MTConnectStreamsResult Parse(string xml)
{
if (string.IsNullOrWhiteSpace(xml))
{
throw new InvalidDataException(
"MTConnect streams response was empty; expected an MTConnectStreams XML document.");
}
// A chunk lifted out of a multipart frame can carry a byte-order mark or the framing's
// trailing line break; XmlReader rejects either ahead of the XML declaration.
using var text = new StringReader(xml.Trim('\uFEFF', ' ', '\t', '\r', '\n'));
using var reader = XmlReader.Create(text, ReaderSettings);
return Read(reader);
}
///
/// Parses one /sample chunk straight from the bytes the multipart framer produced.
///
///
/// The /sample hot path used to materialise every chunk twice — once as a decoded
/// string, once again as the trimmed copy handed to the parser — for a document that is read
/// once and thrown away. Parsing from the bytes drops both copies;
/// does its own UTF-8 decoding off the stream. The framing tolerance the string overload's
/// Trim provided is preserved by , which slices rather than
/// copies.
///
/// The chunk's raw bytes; a UTF-8 BOM and surrounding framing whitespace are tolerated.
/// As for .
public static MTConnectStreamsResult Parse(ReadOnlyMemory xml)
{
var trimmed = TrimFraming(xml);
if (trimmed.IsEmpty)
{
throw new InvalidDataException(
"MTConnect streams response was empty; expected an MTConnectStreams XML document.");
}
using var stream = AsStream(trimmed);
return Parse(stream);
}
/// Parses a /current response read from a stream.
/// A stream positioned at the start of the XML document.
/// As for .
public static MTConnectStreamsResult Parse(Stream stream)
{
ArgumentNullException.ThrowIfNull(stream);
using var reader = XmlReader.Create(stream, ReaderSettings);
return Read(reader);
}
///
/// Slices off what the string overload's Trim('', ' ', '\t', '\r', '\n') removed:
/// a UTF-8 byte-order mark and the multipart framing's surrounding line breaks, either of
/// which rejects ahead of the XML declaration.
///
private static ReadOnlyMemory TrimFraming(ReadOnlyMemory xml)
{
var span = xml.Span;
var start = 0;
var end = span.Length;
while (start < end)
{
if (end - start >= 3 && span[start] == 0xEF && span[start + 1] == 0xBB && span[start + 2] == 0xBF)
{
start += 3;
continue;
}
if (!IsFramingWhitespace(span[start]))
{
break;
}
start++;
}
while (end > start && IsFramingWhitespace(span[end - 1]))
{
end--;
}
return xml[start..end];
}
private static bool IsFramingWhitespace(byte value) =>
value is (byte)' ' or (byte)'\t' or (byte)'\r' or (byte)'\n';
///
/// Views the (already trimmed) bytes as a stream without copying them where the memory is
/// array-backed — which it always is on the /sample path, whose framer hands over the
/// part's own byte[]. That branch aliases the caller's array; only the
/// non-array-backed fallback copies (via ToArray).
///
/// Ownership, so a caller can reason about the aliased buffer: nothing on this path pools
/// or rents it (the framer allocates each byte[] fresh), and parsing is fully
/// synchronous, so the buffer need only stay valid and unmutated for the duration of the
/// Parse call. The returned holds no reference
/// into it — materialises its own strings — so the caller may
/// reuse or discard the buffer as soon as Parse returns.
///
///
private static MemoryStream AsStream(ReadOnlyMemory xml) =>
MemoryMarshal.TryGetArray(xml, out ArraySegment segment) && segment.Array is not null
? new MemoryStream(segment.Array, segment.Offset, segment.Count, writable: false)
: new MemoryStream(xml.ToArray(), writable: false);
///
/// Reads the whole document, translating the reader's own well-formedness complaints into
/// this parser's failure family. A forward-only read raises them wherever it happens to
/// reach the damage rather than all at once up front, so the wrapper spans the entire walk.
///
private static MTConnectStreamsResult Read(XmlReader reader)
{
try
{
return ReadDocument(reader);
}
catch (XmlException ex)
{
throw new InvalidDataException($"MTConnect streams response is not well-formed XML: {ex.Message}", ex);
}
}
private static MTConnectStreamsResult ReadDocument(XmlReader reader)
{
while (reader.Read() && reader.NodeType != XmlNodeType.Element)
{
// Skip the XML declaration, comments and processing instructions ahead of the root.
}
if (reader.NodeType != XmlNodeType.Element)
{
throw new InvalidDataException("MTConnect streams response has no root element.");
}
if (!MTConnectXml.IsNamed(reader, "MTConnectStreams"))
{
// The cold path, and the one place a tree is still built: an MTConnectError arrives in
// exactly this shape (Agents serve one under HTTP 200), and its elements — the
// Agent's own account of what it objected to — are the most useful thing in the whole
// response. Materialising the root is how the shared extraction can reach them.
throw new InvalidDataException(
MTConnectXml.DescribeUnexpectedRoot((XElement)XNode.ReadFrom(reader), "MTConnectStreams", Subject));
}
var observations = new List();
HeaderSequences? header = null;
if (!reader.IsEmptyElement)
{
while (reader.Read() && reader.NodeType != XmlNodeType.EndElement)
{
if (reader.NodeType != XmlNodeType.Element)
{
continue;
}
// The is a direct child of the root, and the first one wins — but it is
// still walked like any other element rather than skipped, so a container nested
// inside it yields its observations exactly as the whole-subtree descent did.
if (header is null && MTConnectXml.IsNamed(reader, "Header"))
{
header = ReadHeader(reader);
}
ReadElement(reader, ContainerKind.None, observations, textSink: null);
}
}
var sequences = header ?? throw new InvalidDataException(
"MTConnect streams response has no ; without it the sequence bookkeeping the sample pump runs on is unknowable.");
return new MTConnectStreamsResult(
sequences.InstanceId,
sequences.NextSequence,
sequences.FirstSequence,
observations);
}
///
/// The three sequence numbers the sample pump runs on, read off the <Header>'s
/// start tag. Read eagerly, where the header is met, so an unusable header is still reported
/// ahead of anything the <Streams> that follows it might also be wrong about.
///
private static HeaderSequences ReadHeader(XmlReader reader) =>
new(
MTConnectXml.RequiredLongAttribute(reader, "instanceId", HeaderOwner, Subject),
MTConnectXml.RequiredLongAttribute(reader, "nextSequence", HeaderOwner, Subject),
MTConnectXml.RequiredLongAttribute(reader, "firstSequence", HeaderOwner, Subject));
///
/// Consumes the element the reader is positioned on, collecting any observation it is and
/// any the rest of its subtree holds. Every element is descended into — observations are
/// found by their parent being one of the three containers, not by walking a fixed
/// DeviceStream → ComponentStream chain, so a vendor's extra nesting level cannot silently
/// hide a device's observations.
///
///
/// Cursor contract: on return the reader sits on the element's
/// , or on the element itself when it was written empty
/// — either way the caller's next lands on whatever follows.
/// Every recursion below relies on that, which is why the children are walked by hand
/// rather than through Skip/ReadSubtree.
///
/// A reader positioned on the element's start tag.
/// What the enclosing element is, which decides whether this one is an observation.
/// Collects the observations found, in document order.
///
/// The text accumulator of the nearest enclosing observation whose value is its text, or
/// null when there is none. Text found anywhere below an observation belongs to it —
/// that is what XElement.Value meant.
///
private static void ReadElement(
XmlReader reader,
ContainerKind parent,
List observations,
ValueSink? textSink)
{
var isObservation = parent != ContainerKind.None;
var isCondition = parent == ContainerKind.Condition;
var kind = ContainerKindOf(reader);
var slot = -1;
string? dataItemId = null;
var timestampUtc = default(DateTime);
string? conditionState = null;
if (isObservation)
{
var name = reader.LocalName;
dataItemId = MTConnectXml.RequiredAttribute(reader, "dataItemId", $"Observation <{name}>", Subject);
timestampUtc = ReadTimestampUtc(reader, dataItemId);
conditionState = isCondition ? ConditionState(name) : null;
// The slot is reserved BEFORE the subtree is read, and filled after: an observation is
// only complete once its text and its child-element flag are known, but appending it
// then would report a nested observation ahead of the one enclosing it. The tree walk
// this replaced was document-order, and callers key snapshots off that order.
slot = observations.Count;
observations.Add(null!);
}
// A CONDITION's value is its element NAME, so nothing below it is collected for it — but an
// enclosing observation's text still is, hence the ancestor sink rather than none at all.
var sink = isObservation && !isCondition ? new ValueSink() : textSink;
var hasElements = !reader.IsEmptyElement && ReadChildren(reader, kind, observations, sink);
if (!isObservation)
{
return;
}
string value;
if (isCondition)
{
value = conditionState!;
}
else
{
var raw = sink!.Value;
// An observation nested inside another one contributes its text to the outer one too,
// which is what concatenating every descendant text node amounted to.
textSink?.Append(raw);
value = raw.Trim();
}
observations[slot] = new MTConnectObservation(
dataItemId!,
value,
timestampUtc,
// A DATA_SET/TABLE observation keeps its content in children, so the
// text read above concatenates them into nonsense ("12" for two entries). Flagged here
// because this is the only layer that can still see the distinction — see
// MTConnectObservation.IsStructured. Never true for a CONDITION: its value comes from
// the element NAME, so child content cannot corrupt it, and flagging one would throw
// away a perfectly well-determined state.
IsStructured: !isCondition && hasElements);
}
///
/// Walks a non-empty element's children through its ,
/// reporting whether any of them was an element.
///
private static bool ReadChildren(
XmlReader reader,
ContainerKind kind,
List observations,
ValueSink? sink)
{
var hasElements = false;
while (reader.Read())
{
switch (reader.NodeType)
{
case XmlNodeType.Element:
hasElements = true;
ReadElement(reader, kind, observations, sink);
break;
// Deliberately not XmlNodeType.Whitespace: ReaderSettings suppresses it, and were it
// ever surfaced it would still not belong in the value — the tree this replaced
// dropped whitespace-only nodes on exactly the same setting.
case XmlNodeType.Text:
case XmlNodeType.CDATA:
case XmlNodeType.SignificantWhitespace:
sink?.Append(reader.Value);
break;
case XmlNodeType.EndElement:
return hasElements;
}
}
// Unreachable on any input the reader accepts: running out of document inside an open
// element is a well-formedness error, which the reader raises from Read() itself.
return hasElements;
}
///
/// A condition's state is its element name. Unavailable is normalized to the
/// spelling so downstream quality mapping has exactly one
/// token to recognize; every other state is reported as-named (Normal, Warning,
/// Fault, and any vendor state).
///
private static string ConditionState(string localName) =>
string.Equals(localName, "Unavailable", StringComparison.OrdinalIgnoreCase) ? UnavailableSentinel : localName;
private static DateTime ReadTimestampUtc(XmlReader reader, string dataItemId)
{
var raw = MTConnectXml.RequiredAttribute(reader, "timestamp", $"Observation '{dataItemId}'", Subject);
if (!DateTime.TryParse(
raw,
CultureInfo.InvariantCulture,
DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal,
out var parsed))
{
throw new InvalidDataException(
$"MTConnect streams response is malformed: observation '{dataItemId}' has an unparseable 'timestamp' ('{raw}').");
}
// AdjustToUniversal already yields Utc; stated explicitly so a future styles change cannot
// quietly hand the OPC UA layer a Local or Unspecified SourceTimestamp.
return DateTime.SpecifyKind(parsed, DateTimeKind.Utc);
}
///
/// Which of the three observation containers — if any — the element the reader is positioned
/// on is. A ComponentStream may carry any subset of them, including none.
///
private static ContainerKind ContainerKindOf(XmlReader reader)
{
if (MTConnectXml.IsNamed(reader, ConditionContainer))
{
return ContainerKind.Condition;
}
return MTConnectXml.IsNamed(reader, "Samples") || MTConnectXml.IsNamed(reader, "Events")
? ContainerKind.Values
: ContainerKind.None;
}
/// What an element's children are: nothing special, valued observations, or conditions.
private enum ContainerKind
{
/// Not an observation container; its element children are just more structure.
None,
/// <Samples> or <Events> — children take their value from their text.
Values,
/// <Condition> — children take their value from their element NAME.
Condition
}
/// The three sequence numbers read off a <Header>.
private readonly record struct HeaderSequences(long InstanceId, long NextSequence, long FirstSequence);
///
/// Accumulates one observation's text across however many nodes the parser hands it, without
/// a for the single-text-node shape every ordinary scalar
/// observation has — which is the shape this parser sees thousands of times a second.
///
private sealed class ValueSink
{
private string? _single;
private StringBuilder? _builder;
/// Everything appended so far, untrimmed — XElement.Value's concatenation.
public string Value => _builder?.ToString() ?? _single ?? string.Empty;
public void Append(string text)
{
if (_builder is not null)
{
_builder.Append(text);
return;
}
if (_single is null)
{
_single = text;
return;
}
_builder = new StringBuilder(_single).Append(text);
_single = null;
}
}
}