5ba9f1be6f
Code review of c46540ae approved the framing algorithm but moved the risk onto the contract around it. C1 (critical). SampleAsync returned normally on three different events: EOF, the closing --boundary--, and a response that was not multipart at all (where it yielded one parsed snapshot and finished). The seam documents the stream as yielding until ct is cancelled, so a Task 11 pump written to that contract would silently drop its subscription or hot-loop reconnecting; the non-multipart leg reported a configuration error as a healthy finished stream, which is the #485 quiet-successful-termination shape aimed at the very next task. Every non-cancellation end now throws: MTConnectStreamEndedException (ConnectionClosed / ClosingBoundary - transient) or MTConnectStreamNotSupportedException (configuration - reconnecting reproduces it forever), sharing one catchable base. The non-multipart body is still parsed first so an Agent's own MTConnectError text wins, but the document is NOT yielded. I1. IsSequenceGap moved to IMTConnectAgentClient (static) and its first parameter is now expectedFrom. The old name and doc were wrong for every chunk after the first - the correct comparand is the PREVIOUS chunk's NextSequence, and comparing against the opening "from" argument reports a gap on every chunk once the ring buffer rolls, i.e. an endless /current re-baseline storm. Both doc blocks also now record that cppagent answers from < firstSequence with an OUT_OF_RANGE MTConnectError under HTTP 200, which surfaces as InvalidDataException and NOT as a gap - so Task 11 must treat that parse failure as a re-baseline trigger too. I2. Every multipart test served the whole body from one buffer, so every framing test completed in a single ReadAsync - the split-boundary path, the split-header path and the multi-fill loop were correct by inspection only. A ChunkedStream double (N bytes per read, N = 1/3/7) now drives the fixtures through a split transport, plus a >16 KB document that forces a buffer resize. Falsifiability: removing either scan overlap, or collapsing the fill loop, fails ONLY the new chunked tests. I3. Disposal mid-enumeration surfaces as OperationCanceledException via an internal dispose-linked token, not a raw ObjectDisposedException that Task 9's re-init would inflict on an enumerating pump. I5. HeartbeatMs, SampleIntervalMs and SampleCount join RequestTimeoutMs in construction-time positive-value validation. All three reach the query string and an Agent answers heartbeat=0 with an HTTP-200 MTConnectError that names no config key. I4/M2. The no-Content-length framing fallback logs a one-shot warning (the client takes an optional ILogger); a multipart response with no boundary parameter fails fast instead of degrading into a watchdog timeout. M5/M6. Shared element/attribute reading rules extracted to MTConnectXml; the probe parser documents why it alone does not trim BOM/whitespace. The literal U+FEFF in source became a '' escape. Also, from Task 8: MTConnectObservation gained IsStructured (default false), set from element.HasElements. A DATA_SET/TABLE observation's <Entry> children concatenate through element.Value to nonsense ("12" for two entries) that the index would publish as Good, and only the parser can tell that apart from a legitimate space-bearing Message. False for CONDITION observations - their value comes from the element name, so child content cannot corrupt it. 275/275 green in this suite's own files.
190 lines
9.1 KiB
C#
190 lines
9.1 KiB
C#
using System.Xml;
|
|
using System.Xml.Linq;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
|
|
|
/// <summary>
|
|
/// Turns an MTConnect Agent <c>/probe</c> response (an <c>MTConnectDevices</c> XML document)
|
|
/// into the neutral <see cref="MTConnectProbeModel"/>. Deliberately a pure, socket-free static
|
|
/// so the whole parse is unit-testable against canned fixtures.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Why hand-rolled rather than TrakHound.</b> The TrakHound packages Task 0 pinned
|
|
/// (<c>MTConnect.NET-Common</c> + <c>-HTTP</c> 6.9.0.2 — both dropped in Task 7) contain <b>no</b>
|
|
/// <c>IResponseDocumentFormatter</c> implementation — the XML formatter ships in the
|
|
/// separate, unreferenced <c>MTConnect.NET-XML</c> package — so
|
|
/// <c>ResponseDocumentFormatter.CreateDevicesResponseDocument("xml", stream)</c> answers
|
|
/// <c>Success = false</c> / "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.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Three shapes of the wire format this parser is built around.</b>
|
|
/// (1) A device may declare data items <i>directly</i> on <c><Device><DataItems></c>,
|
|
/// outside any component — a walker that only recurses <c><Components></c> silently
|
|
/// drops them.
|
|
/// (2) The children of <c><Components></c> are named for the component <i>type</i>
|
|
/// (<c><Controller></c>, <c><Path></c>, <c><Axes></c>, …) — there is no
|
|
/// literal <c><Component></c> element — so <i>every</i> element child of
|
|
/// <c><Components></c> is a component.
|
|
/// (3) The document is XML-namespaced and the namespace URI carries the MTConnect version
|
|
/// (<c>urn:mtconnect.org:MTConnectDevices:1.3</c> … <c>:2.0</c>). Element matching and
|
|
/// attribute reading therefore go through <see cref="MTConnectXml"/>, whose two rules
|
|
/// (match on local name, read attributes unqualified) are shared verbatim with
|
|
/// <see cref="MTConnectStreamsParser"/> — see that type for why each rule exists.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>One deliberate divergence from the streams parser: no BOM/whitespace trim here.</b>
|
|
/// A <c>/probe</c> body arrives whole from <c>HttpContent</c>, so it begins exactly where
|
|
/// the Agent's document begins. A <c>/sample</c> 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 <c>XmlReader</c> rejects — hence the trim there and not here.
|
|
/// This asymmetry is intentional, not an oversight.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Failure posture.</b> Anything that is not a well-formed MTConnect device model throws
|
|
/// <see cref="InvalidDataException"/>. 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal static class MTConnectProbeParser
|
|
{
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
private const int MaxComponentDepth = 64;
|
|
|
|
/// <summary>How <see cref="MTConnectXml"/>'s shared error messages name this document.</summary>
|
|
private const string Subject = "/probe response";
|
|
|
|
/// <summary>Parses a <c>/probe</c> response held as a string.</summary>
|
|
/// <param name="xml">The raw <c>MTConnectDevices</c> XML document.</param>
|
|
/// <exception cref="InvalidDataException">
|
|
/// The payload is empty, not well-formed XML, not an <c>MTConnectDevices</c> document (an
|
|
/// Agent <c>MTConnectError</c> document included — Agents answer a bad device path with one
|
|
/// under HTTP 200), or declares no usable device.
|
|
/// </exception>
|
|
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);
|
|
}
|
|
|
|
/// <summary>Parses a <c>/probe</c> response read from a stream.</summary>
|
|
/// <param name="stream">A stream positioned at the start of the XML document.</param>
|
|
/// <exception cref="InvalidDataException">As for <see cref="Parse(string)"/>.</exception>
|
|
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 <Devices> 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));
|
|
|
|
/// <summary>
|
|
/// Every element child of this container's <c><Components></c> element, whatever it is
|
|
/// named — the element name is the component type, not the literal string "Component".
|
|
/// </summary>
|
|
private static IReadOnlyList<MTConnectComponent> 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<MTConnectDataItem> 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));
|
|
}
|
|
|
|
}
|