feat(mtconnect): agent-client observation write (HTTP POST, AllowPut surface)

This commit is contained in:
Joseph Doherty
2026-08-05 06:16:35 -04:00
parent b5e39e1f32
commit 9f17d7efa2
4 changed files with 580 additions and 7 deletions
@@ -1,5 +1,27 @@
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <summary>
/// The result of a <see cref="IMTConnectAgentClient.WriteObservationAsync"/> call, as the
/// <b>Agent</b> answered it. A transport failure is never represented here — it throws (see the
/// method's contract), so an outcome always means "the Agent replied, and this is what it said".
/// </summary>
/// <param name="Accepted">
/// <c>true</c> when the Agent took the observation: a 2xx response whose body is not an
/// <c>MTConnectError</c> document. <c>false</c> is a refusal, and
/// <see cref="AgentError"/> then always carries text.
/// </param>
/// <param name="AgentError">
/// Why the Agent refused, in its own words where it supplied any — the joined
/// <c>errorCode: text</c> of the <c>MTConnectError</c> document, or the HTTP status line for a
/// non-2xx answer. <c>null</c> if and only if <see cref="Accepted"/> is <c>true</c>.
/// <para>
/// This is Agent-authored text destined for a <b>log line</b>, never for a driver's
/// <c>LastError</c> (which is type-only by repo-wide discipline) and never for an OPC UA
/// status code's own message.
/// </para>
/// </param>
public sealed record MTConnectWriteOutcome(bool Accepted, string? AgentError);
/// <summary>
/// The seam between the MTConnect driver and an MTConnect Agent's HTTP surface
/// (<c>/probe</c>, <c>/current</c>, <c>/sample</c>). This interface is transport-neutral by
@@ -17,7 +39,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <c>/sample</c> long poll. (Task 0 planned to build on the TrakHound MTConnect.NET libraries;
/// Tasks 6 and 7 proved they can neither parse a document nor frame the stream socket-free, and
/// dropped the references — see the Task 0 CORRECTION block in the plan.) A fake for tests only
/// needs to implement these three instance members; <see cref="IsSequenceGap"/> is a static
/// needs to implement these four instance members; <see cref="IsSequenceGap"/> is a static
/// protocol rule that lives here, rather than on the implementation, so a consumer written
/// against this seam never has to reference the concrete client.
/// </para>
@@ -88,6 +110,67 @@ public interface IMTConnectAgentClient : IDisposable
/// <param name="ct">Cancellation token; cancelling is the contracted way to end the stream.</param>
IAsyncEnumerable<MTConnectStreamsResult> SampleAsync(long from, CancellationToken ct);
/// <summary>
/// Sets the Agent's observation for one DataItem, through the Agent's <c>AllowPut</c>
/// surface (<c>POST {agent}/{deviceName}?{dataItemId}={value}</c>).
/// </summary>
/// <remarks>
/// <para>
/// <b>This is observation injection, not machine command.</b> The value lands in the
/// Agent's observation buffer exactly as if an adapter had reported it — every consumer
/// of that Agent then sees it, and the next adapter/SHDR update for the same DataItem
/// overwrites it. Nothing is sent to the machine. It is also gated twice and off at both
/// gates by default: the Agent's own <c>AllowPut</c> (<c>agent.cfg</c>) and the driver's
/// <c>write.enabled</c>.
/// </para>
/// <para>
/// <b>A refusal is a return value; a transport failure is an exception.</b> The split is
/// the whole point of the outcome type, and it is what lets the driver map the two to
/// different OPC UA status codes without inspecting exception text:
/// <list type="bullet">
/// <item>
/// The Agent answered and <b>took</b> the observation (2xx, body empty or not an
/// <c>MTConnectError</c>) ⇒ <see cref="MTConnectWriteOutcome.Accepted"/>. An
/// <b>empty 2xx body is acceptance</b> — that is what a cppagent actually sends,
/// so treating a body-less answer as suspect would refuse every successful write.
/// </item>
/// <item>
/// The Agent answered and <b>refused</b> — a non-2xx status, or an
/// <c>MTConnectError</c> document served (as Agents do) under <b>HTTP 200</b> ⇒
/// not accepted, with the Agent's own text in
/// <see cref="MTConnectWriteOutcome.AgentError"/>. The 200-with-error shape is
/// why the status code alone can never decide this.
/// </item>
/// <item>
/// The Agent could not be <b>reached or did not answer in time</b> ⇒ throws
/// (<see cref="HttpRequestException"/>, or a <see cref="TimeoutException"/> for
/// the per-call deadline; <see cref="OperationCanceledException"/> when
/// <paramref name="cancellationToken"/> is cancelled or the client is disposed).
/// Folding these into a <c>false</c> outcome would make "the machine's Agent is
/// unreachable" indistinguishable from "the Agent rejected this DataItem" —
/// identical to the caller, opposite in what an operator must do about it.
/// </item>
/// </list>
/// </para>
/// <para>
/// <b>The write is a URL, so escaping is this seam's job, not the caller's.</b>
/// <paramref name="dataItemId"/> and <paramref name="value"/> are passed <i>unescaped</i>
/// and are percent-encoded by the implementation. A caller that pre-escaped would
/// double-encode and write a literal <c>%20</c> into the Agent's buffer, which the Agent
/// accepts — so the corruption is silent and shows up only as a wrong value on read-back.
/// </para>
/// </remarks>
/// <param name="deviceName">
/// The device that owns the DataItem — the path segment of the request. Required even on a
/// single-device Agent, because the Agent's PUT surface is addressed per device.
/// </param>
/// <param name="dataItemId">The DataItem's <c>id</c>, as declared by <c>/probe</c>. Unescaped.</param>
/// <param name="value">The value to report, already rendered to its invariant string form. Unescaped.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>What the Agent said — see the remarks.</returns>
Task<MTConnectWriteOutcome> WriteObservationAsync(
string deviceName, string dataItemId, string value, CancellationToken cancellationToken);
/// <summary>
/// Has the Agent's ring buffer overflowed past the sequence the caller expected next? True
/// when the chunk's oldest retained sequence is <i>strictly</i> newer than
@@ -1,6 +1,8 @@
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Text;
using System.Xml;
using System.Xml.Linq;
using Microsoft.Extensions.Logging;
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
@@ -75,6 +77,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
private readonly ILogger? _logger;
private readonly Uri _probeUri;
private readonly Uri _currentUri;
private readonly string _agentRoot;
private readonly string _sampleUriBase;
private readonly string _sampleQuerySuffix;
private readonly TimeSpan _requestTimeout;
@@ -133,6 +136,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
// Agent and an idle one both emit at least one chunk per heartbeat.
_streamIdleTimeout = TimeSpan.FromMilliseconds((2L * options.HeartbeatMs) + options.RequestTimeoutMs);
_agentRoot = ResolveAgentRoot(options);
_probeUri = BuildRequestUri(options, "probe");
_currentUri = BuildRequestUri(options, "current");
_sampleUriBase = BuildRequestUri(options, "sample").AbsoluteUri;
@@ -242,6 +246,139 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
}
}
/// <inheritdoc/>
/// <remarks>
/// Issued on <c>_http</c>, the <b>unary</b> client — the write is a short request/response
/// and must be bounded by <c>RequestTimeoutMs</c> like the other unary legs. Sending it on
/// the streaming client would inherit that client's infinite timeout, and a frozen Agent
/// would park a write forever with nothing to bound it (the streaming leg's heartbeat
/// watchdog protects a multipart body, not a one-shot POST).
/// </remarks>
public async Task<MTConnectWriteOutcome> WriteObservationAsync(
string deviceName, string dataItemId, string value, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ArgumentException.ThrowIfNullOrWhiteSpace(deviceName);
ArgumentException.ThrowIfNullOrWhiteSpace(dataItemId);
ArgumentNullException.ThrowIfNull(value);
var uri = BuildWriteUri(deviceName, dataItemId, value);
using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _disposeCts.Token);
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);
deadline.CancelAfter(_requestTimeout);
using var request = new HttpRequestMessage(HttpMethod.Post, uri);
HttpResponseMessage response;
try
{
response = await _http.SendAsync(request, deadline.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!lifetime.IsCancellationRequested)
{
throw TimedOut(uri);
}
using (response)
{
string body;
try
{
body = await response.Content.ReadAsStringAsync(deadline.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!lifetime.IsCancellationRequested)
{
throw TimedOut(uri);
}
// Deliberately NOT EnsureSuccessStatusCode: a refused write is an answer, and the caller
// needs it as a value it can map to a per-tag status code rather than as an exception
// indistinguishable from the Agent being unreachable.
var agentError = DescribeAgentError(body);
if (!response.IsSuccessStatusCode)
{
var status = $"HTTP {(int)response.StatusCode} {response.ReasonPhrase}".TrimEnd();
return new MTConnectWriteOutcome(
Accepted: false, agentError is null ? status : $"{status}: {agentError}");
}
// An MTConnectError document under HTTP 200 is how an Agent actually refuses; the status
// code alone would report it as a success.
return agentError is null
? new MTConnectWriteOutcome(Accepted: true, AgentError: null)
: new MTConnectWriteOutcome(Accepted: false, agentError);
}
}
/// <summary>
/// The Agent's <c>AllowPut</c> URL: the device as a path segment, the DataItem id and value
/// as a query pair. Every caller-supplied part is percent-encoded here — see the seam's
/// contract for why escaping cannot be the caller's job.
/// </summary>
private Uri BuildWriteUri(string deviceName, string dataItemId, string value)
{
var device = Uri.EscapeDataString(deviceName.Trim());
var query = $"{Uri.EscapeDataString(dataItemId)}={Uri.EscapeDataString(value)}";
return new Uri($"{_agentRoot}/{device}?{query}", UriKind.Absolute);
}
/// <summary>
/// The Agent's own refusal text when <paramref name="body"/> is an <c>MTConnectError</c>
/// document, else <c>null</c> (including for an empty body, a non-XML body, and any other
/// MTConnect document).
/// </summary>
/// <remarks>
/// Read by <see cref="XName.LocalName"/> only, like every other document this driver parses:
/// the namespace URI carries the MTConnect version, so a qualified match would silently stop
/// recognising errors the day an Agent is upgraded — and an unrecognised error reads as a
/// successful write.
/// </remarks>
private static string? DescribeAgentError(string body)
{
if (string.IsNullOrWhiteSpace(body))
{
return null;
}
XElement? root;
try
{
root = XDocument.Parse(body).Root;
}
catch (XmlException)
{
// Not XML at all — an Agent that answered 2xx with a plain-text or HTML body has not
// refused anything, so this is not the place to invent a failure.
return null;
}
if (root is null || !MTConnectXml.IsNamed(root, "MTConnectError"))
{
return null;
}
var errors = root.Descendants()
.Where(e => MTConnectXml.IsNamed(e, "Error"))
.Select(e =>
{
var code = MTConnectXml.OptionalAttribute(e, "errorCode");
var text = e.Value.Trim();
return code is null ? text : $"{code}: {text}";
})
.Where(text => text.Length > 0)
.ToList();
// An error document with no readable detail is still a refusal; reporting it as null would
// turn it back into an acceptance.
return errors.Count == 0
? "The Agent answered with an MTConnectError document carrying no error detail."
: string.Join("; ", errors);
}
/// <inheritdoc/>
public void Dispose()
{
@@ -379,7 +516,31 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
return boundary;
}
private static Uri BuildRequestUri(MTConnectDriverOptions options, string request)
/// <summary>
/// A read path's request URI, scoped to the configured device. Reads <see cref="_agentRoot"/>,
/// so it may only be called after that field is assigned — which the constructor does first.
/// </summary>
private Uri BuildRequestUri(MTConnectDriverOptions options, string request)
{
var deviceName = options.DeviceName?.Trim();
var deviceSegment = string.IsNullOrEmpty(deviceName) ? string.Empty : $"/{Uri.EscapeDataString(deviceName)}";
return new Uri($"{_agentRoot}{deviceSegment}/{request}", UriKind.Absolute);
}
/// <summary>
/// Validates <see cref="MTConnectDriverOptions.AgentUri"/> and reduces it to the base every
/// request path is appended to: its left part up to and including the path (so a proxied
/// Agent keeps its prefix), with any trailing slash removed so callers can append
/// <c>/segment</c> unconditionally.
/// </summary>
/// <remarks>
/// Extracted rather than duplicated because the write path resolves its device at call time,
/// not at construction: two copies of this rule would drift, and the drift would surface as
/// writes landing on a URL the reads never used — against an Agent that answers 200 either
/// way.
/// </remarks>
private static string ResolveAgentRoot(MTConnectDriverOptions options)
{
var agentUri = options.AgentUri?.Trim();
if (string.IsNullOrEmpty(agentUri))
@@ -395,11 +556,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
$"{nameof(MTConnectDriverOptions.AgentUri)} '{agentUri}' is not an absolute http(s) URI.", nameof(options));
}
var root = parsed.GetLeftPart(UriPartial.Path).TrimEnd('/');
var deviceName = options.DeviceName?.Trim();
var deviceSegment = string.IsNullOrEmpty(deviceName) ? string.Empty : $"/{Uri.EscapeDataString(deviceName)}";
return new Uri($"{root}{deviceSegment}/{request}", UriKind.Absolute);
return parsed.GetLeftPart(UriPartial.Path).TrimEnd('/');
}
/// <summary>