feat(mtconnect): agent-client observation write (HTTP POST, AllowPut surface)
This commit is contained in:
@@ -1,5 +1,27 @@
|
|||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
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>
|
/// <summary>
|
||||||
/// The seam between the MTConnect driver and an MTConnect Agent's HTTP surface
|
/// 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
|
/// (<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;
|
/// <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
|
/// 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
|
/// 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
|
/// protocol rule that lives here, rather than on the implementation, so a consumer written
|
||||||
/// against this seam never has to reference the concrete client.
|
/// against this seam never has to reference the concrete client.
|
||||||
/// </para>
|
/// </para>
|
||||||
@@ -88,6 +110,67 @@ public interface IMTConnectAgentClient : IDisposable
|
|||||||
/// <param name="ct">Cancellation token; cancelling is the contracted way to end the stream.</param>
|
/// <param name="ct">Cancellation token; cancelling is the contracted way to end the stream.</param>
|
||||||
IAsyncEnumerable<MTConnectStreamsResult> SampleAsync(long from, CancellationToken ct);
|
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>
|
/// <summary>
|
||||||
/// Has the Agent's ring buffer overflowed past the sequence the caller expected next? True
|
/// 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
|
/// when the chunk's oldest retained sequence is <i>strictly</i> newer than
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Xml;
|
||||||
|
using System.Xml.Linq;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
|
||||||
@@ -75,6 +77,7 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
|||||||
private readonly ILogger? _logger;
|
private readonly ILogger? _logger;
|
||||||
private readonly Uri _probeUri;
|
private readonly Uri _probeUri;
|
||||||
private readonly Uri _currentUri;
|
private readonly Uri _currentUri;
|
||||||
|
private readonly string _agentRoot;
|
||||||
private readonly string _sampleUriBase;
|
private readonly string _sampleUriBase;
|
||||||
private readonly string _sampleQuerySuffix;
|
private readonly string _sampleQuerySuffix;
|
||||||
private readonly TimeSpan _requestTimeout;
|
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.
|
// Agent and an idle one both emit at least one chunk per heartbeat.
|
||||||
_streamIdleTimeout = TimeSpan.FromMilliseconds((2L * options.HeartbeatMs) + options.RequestTimeoutMs);
|
_streamIdleTimeout = TimeSpan.FromMilliseconds((2L * options.HeartbeatMs) + options.RequestTimeoutMs);
|
||||||
|
|
||||||
|
_agentRoot = ResolveAgentRoot(options);
|
||||||
_probeUri = BuildRequestUri(options, "probe");
|
_probeUri = BuildRequestUri(options, "probe");
|
||||||
_currentUri = BuildRequestUri(options, "current");
|
_currentUri = BuildRequestUri(options, "current");
|
||||||
_sampleUriBase = BuildRequestUri(options, "sample").AbsoluteUri;
|
_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/>
|
/// <inheritdoc/>
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
@@ -379,7 +516,31 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
|
|||||||
return boundary;
|
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();
|
var agentUri = options.AgentUri?.Trim();
|
||||||
if (string.IsNullOrEmpty(agentUri))
|
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));
|
$"{nameof(MTConnectDriverOptions.AgentUri)} '{agentUri}' is not an absolute http(s) URI.", nameof(options));
|
||||||
}
|
}
|
||||||
|
|
||||||
var root = parsed.GetLeftPart(UriPartial.Path).TrimEnd('/');
|
return 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -355,6 +355,32 @@ internal sealed class CannedAgentClient : IMTConnectAgentClient, IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Every write this Agent was asked to take, in order — the record a driver-level write test
|
||||||
|
/// asserts against (which DataItem, on which device, carrying what).
|
||||||
|
/// </summary>
|
||||||
|
public ConcurrentQueue<(string DeviceName, string DataItemId, string Value)> Writes { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What <see cref="WriteObservationAsync"/> answers with. Defaults to acceptance; set it to a
|
||||||
|
/// refusal to script the Agent's <c>MTConnectError</c> leg.
|
||||||
|
/// </summary>
|
||||||
|
public MTConnectWriteOutcome WriteOutcome { get; set; } = new(Accepted: true, AgentError: null);
|
||||||
|
|
||||||
|
/// <summary>When set, a write throws this instead of answering — the transport-failure leg.</summary>
|
||||||
|
public Exception? WriteFailure { get; set; }
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public Task<MTConnectWriteOutcome> WriteObservationAsync(
|
||||||
|
string deviceName, string dataItemId, string value, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(IsDisposed, this);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
Writes.Enqueue((deviceName, dataItemId, value));
|
||||||
|
|
||||||
|
return WriteFailure is null ? Task.FromResult(WriteOutcome) : throw WriteFailure;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Queues a chunk onto the scripted <c>/sample</c> stream and waits until the consumer has
|
/// Queues a chunk onto the scripted <c>/sample</c> stream and waits until the consumer has
|
||||||
/// processed it. This is the deterministic replacement for "wait a bit and hope the pump
|
/// processed it. This is the deterministic replacement for "wait a bit and hope the pump
|
||||||
|
|||||||
+307
@@ -0,0 +1,307 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The wire contract of <see cref="MTConnectAgentClient.WriteObservationAsync"/> — the Agent's
|
||||||
|
/// <c>AllowPut</c> surface. Everything here runs through a stubbed
|
||||||
|
/// <see cref="HttpMessageHandler"/> via the internal test-seam constructor, so the verb, the
|
||||||
|
/// exact request URI, and each response shape are asserted with no socket and no Agent.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The URI assertions are the point of this class, not incidental detail.</b> A write is
|
||||||
|
/// nothing but a URL: get the escaping or the device segment wrong and the Agent still
|
||||||
|
/// answers 200, having stored a value under a DataItem id nobody asked about — a corruption
|
||||||
|
/// with no error anywhere, visible only on a later read-back. There is no encoder to unit
|
||||||
|
/// test, so this is the only place that shape is pinned.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The refusal legs pin the split the seam contract rests on:</b> an Agent that answered
|
||||||
|
/// and said no is a returned outcome, while an Agent that could not be reached throws. The
|
||||||
|
/// 200-carrying-an-<c>MTConnectError</c> case is why the status code alone can never decide
|
||||||
|
/// it — that is the shape a real cppagent uses to report every objection.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Trait("Category", "Unit")]
|
||||||
|
public sealed class MTConnectAgentClientWriteTests
|
||||||
|
{
|
||||||
|
// ── stub handler ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Answers every request with a canned response (or throws) via a delegate, with no socket.</summary>
|
||||||
|
private sealed class StubHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> respond)
|
||||||
|
: HttpMessageHandler
|
||||||
|
{
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct) =>
|
||||||
|
respond(request, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Records the requests it is handed, then answers each with <paramref name="respond"/>.</summary>
|
||||||
|
private sealed class RecordingHandler(Func<HttpRequestMessage, HttpResponseMessage> respond) : HttpMessageHandler
|
||||||
|
{
|
||||||
|
public List<(HttpMethod Method, Uri Uri)> Requests { get; } = [];
|
||||||
|
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
Requests.Add((request.Method, request.RequestUri!));
|
||||||
|
|
||||||
|
return Task.FromResult(respond(request));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MTConnectDriverOptions Options(string agentUri = "http://stub-agent:5000") =>
|
||||||
|
new() { AgentUri = agentUri };
|
||||||
|
|
||||||
|
private static HttpResponseMessage Ok(string body = "") => new(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent(body, Encoding.UTF8, "text/plain"),
|
||||||
|
};
|
||||||
|
|
||||||
|
private static HttpResponseMessage XmlOk(string xml) => new(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent(xml, Encoding.UTF8, "text/xml"),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Exactly the shape a cppagent uses to refuse: an error document served under HTTP 200.</summary>
|
||||||
|
private const string MTConnectErrorXml =
|
||||||
|
"""
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<MTConnectError xmlns="urn:mtconnect.org:MTConnectError:1.3">
|
||||||
|
<Header creationTime="2026-08-05T12:00:00Z" sender="stub-agent" instanceId="1" version="1.3.0.12" bufferSize="1"/>
|
||||||
|
<Errors>
|
||||||
|
<Error errorCode="UNSUPPORTED">Cannot find device data item: nosuchitem</Error>
|
||||||
|
</Errors>
|
||||||
|
</MTConnectError>
|
||||||
|
""";
|
||||||
|
|
||||||
|
// ── verb + URI ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_write_is_issued_as_a_POST()
|
||||||
|
{
|
||||||
|
var handler = new RecordingHandler(_ => Ok());
|
||||||
|
using var client = new MTConnectAgentClient(Options(), handler);
|
||||||
|
|
||||||
|
await client.WriteObservationAsync("StubDevice", "dev1_msg", "hello", CancellationToken.None);
|
||||||
|
|
||||||
|
handler.Requests.ShouldHaveSingleItem().Method.ShouldBe(HttpMethod.Post);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The device is a <b>path segment</b> and the DataItem id/value are a <b>query pair</b> —
|
||||||
|
/// the Agent's documented AllowPut shape. Asserted as the whole URI rather than in parts,
|
||||||
|
/// because a fragment-wise assertion passes on a URI that is assembled in the wrong order.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task The_request_uri_is_the_agents_AllowPut_shape()
|
||||||
|
{
|
||||||
|
var handler = new RecordingHandler(_ => Ok());
|
||||||
|
using var client = new MTConnectAgentClient(Options(), handler);
|
||||||
|
|
||||||
|
await client.WriteObservationAsync("StubDevice", "dev1_msg", "hello", CancellationToken.None);
|
||||||
|
|
||||||
|
handler.Requests.ShouldHaveSingleItem().Uri.AbsoluteUri
|
||||||
|
.ShouldBe("http://stub-agent:5000/StubDevice?dev1_msg=hello");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A value carrying a space, an <c>&</c> and a <c>=</c> — each of which silently ends the
|
||||||
|
/// query pair early if it reaches the wire raw — plus a DataItem id needing the same
|
||||||
|
/// treatment. Unescaped, the Agent would take a 200 and store the truncated value.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task The_dataItemId_and_value_are_percent_encoded()
|
||||||
|
{
|
||||||
|
var handler = new RecordingHandler(_ => Ok());
|
||||||
|
using var client = new MTConnectAgentClient(Options(), handler);
|
||||||
|
|
||||||
|
await client.WriteObservationAsync(
|
||||||
|
"Stub Device", "dev1 msg&x", "Coolant low = 20% & rising", CancellationToken.None);
|
||||||
|
|
||||||
|
handler.Requests.ShouldHaveSingleItem().Uri.AbsoluteUri.ShouldBe(
|
||||||
|
"http://stub-agent:5000/Stub%20Device?dev1%20msg%26x=Coolant%20low%20%3D%2020%25%20%26%20rising");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The base URI's own path is preserved (an Agent behind a reverse-proxy prefix) and a
|
||||||
|
/// trailing slash does not double up — the same left-part rule the read paths use.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task A_based_agent_uri_keeps_its_path_prefix_and_does_not_double_its_slash()
|
||||||
|
{
|
||||||
|
var handler = new RecordingHandler(_ => Ok());
|
||||||
|
using var client = new MTConnectAgentClient(Options("http://stub-agent:5000/mtconnect/"), handler);
|
||||||
|
|
||||||
|
await client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None);
|
||||||
|
|
||||||
|
handler.Requests.ShouldHaveSingleItem().Uri.AbsoluteUri
|
||||||
|
.ShouldBe("http://stub-agent:5000/mtconnect/StubDevice?dev1_msg=hi");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The <c>deviceName</c> argument decides the path — <b>not</b> the options' device scope. A
|
||||||
|
/// multi-device Agent is addressed per write, so an implementation that reused the read
|
||||||
|
/// paths' scoped URI would write every DataItem onto one device.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task The_device_segment_comes_from_the_argument_not_the_configured_device_scope()
|
||||||
|
{
|
||||||
|
var handler = new RecordingHandler(_ => Ok());
|
||||||
|
using var client = new MTConnectAgentClient(
|
||||||
|
new MTConnectDriverOptions { AgentUri = "http://stub-agent:5000", DeviceName = "ScopedDevice" },
|
||||||
|
handler);
|
||||||
|
|
||||||
|
await client.WriteObservationAsync("OtherDevice", "dev2_msg", "hi", CancellationToken.None);
|
||||||
|
|
||||||
|
handler.Requests.ShouldHaveSingleItem().Uri.AbsoluteUri
|
||||||
|
.ShouldBe("http://stub-agent:5000/OtherDevice?dev2_msg=hi");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── acceptance ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An empty 2xx body is what a real cppagent sends on success. Treating a body-less answer
|
||||||
|
/// as anything but acceptance would refuse every write that actually worked.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task An_empty_200_is_accepted()
|
||||||
|
{
|
||||||
|
using var client = new MTConnectAgentClient(Options(), new StubHandler((_, _) => Task.FromResult(Ok())));
|
||||||
|
|
||||||
|
var outcome = await client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None);
|
||||||
|
|
||||||
|
outcome.Accepted.ShouldBeTrue();
|
||||||
|
outcome.AgentError.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A 200 carrying a non-MTConnect body is still acceptance — only an error document refuses.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task A_200_whose_body_is_not_an_error_document_is_accepted()
|
||||||
|
{
|
||||||
|
using var client = new MTConnectAgentClient(
|
||||||
|
Options(), new StubHandler((_, _) => Task.FromResult(Ok("<html><body>OK</body></html>"))));
|
||||||
|
|
||||||
|
var outcome = await client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None);
|
||||||
|
|
||||||
|
outcome.Accepted.ShouldBeTrue();
|
||||||
|
outcome.AgentError.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── refusal ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The load-bearing refusal: HTTP 200 with an <c>MTConnectError</c> body.
|
||||||
|
/// <c>EnsureSuccessStatusCode</c> never fires here, so an implementation that trusted the
|
||||||
|
/// status code would report every rejected write as a success.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task An_MTConnectError_under_200_is_a_refusal_carrying_the_agents_own_text()
|
||||||
|
{
|
||||||
|
using var client = new MTConnectAgentClient(
|
||||||
|
Options(), new StubHandler((_, _) => Task.FromResult(XmlOk(MTConnectErrorXml))));
|
||||||
|
|
||||||
|
var outcome = await client.WriteObservationAsync(
|
||||||
|
"StubDevice", "nosuchitem", "hi", CancellationToken.None);
|
||||||
|
|
||||||
|
outcome.Accepted.ShouldBeFalse();
|
||||||
|
outcome.AgentError.ShouldNotBeNull();
|
||||||
|
outcome.AgentError.ShouldContain("UNSUPPORTED");
|
||||||
|
outcome.AgentError.ShouldContain("Cannot find device data item: nosuchitem");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task A_non_success_status_is_a_refusal_naming_the_status()
|
||||||
|
{
|
||||||
|
using var client = new MTConnectAgentClient(
|
||||||
|
Options(),
|
||||||
|
new StubHandler((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)
|
||||||
|
{
|
||||||
|
Content = new StringContent(string.Empty),
|
||||||
|
})));
|
||||||
|
|
||||||
|
var outcome = await client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None);
|
||||||
|
|
||||||
|
outcome.Accepted.ShouldBeFalse();
|
||||||
|
outcome.AgentError.ShouldNotBeNull();
|
||||||
|
outcome.AgentError.ShouldContain("404");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── transport failure throws, never a false outcome ──────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// "Unreachable" must stay distinguishable from "rejected". Returning
|
||||||
|
/// <c>Accepted = false</c> here would collapse a comms fault into a per-tag refusal, which
|
||||||
|
/// the driver maps to a different status code and an operator resolves differently.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task A_transport_failure_propagates_rather_than_becoming_a_refusal()
|
||||||
|
{
|
||||||
|
var boom = new HttpRequestException("dial failed");
|
||||||
|
using var client = new MTConnectAgentClient(
|
||||||
|
Options(), new StubHandler((_, _) => Task.FromException<HttpResponseMessage>(boom)));
|
||||||
|
|
||||||
|
var thrown = await Should.ThrowAsync<HttpRequestException>(
|
||||||
|
() => client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None));
|
||||||
|
|
||||||
|
thrown.ShouldBeSameAs(boom);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A frozen Agent must surface as a bounded <see cref="TimeoutException"/> naming the URI,
|
||||||
|
/// not park the caller — the same per-call deadline discipline the read legs carry
|
||||||
|
/// (arch-review R2-01).
|
||||||
|
/// </summary>
|
||||||
|
[Fact(Timeout = 10000)]
|
||||||
|
public async Task A_frozen_agent_times_out_within_the_per_call_deadline()
|
||||||
|
{
|
||||||
|
using var client = new MTConnectAgentClient(
|
||||||
|
new MTConnectDriverOptions { AgentUri = "http://stub-agent:5000", RequestTimeoutMs = 300 },
|
||||||
|
new StubHandler(async (_, ct) =>
|
||||||
|
{
|
||||||
|
await Task.Delay(Timeout.Infinite, ct);
|
||||||
|
|
||||||
|
throw new UnreachableException();
|
||||||
|
}));
|
||||||
|
|
||||||
|
await Should.ThrowAsync<TimeoutException>(
|
||||||
|
() => client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Local stand-in so the stub handler's "never returns" branch has a throw statement to satisfy flow analysis.</summary>
|
||||||
|
private sealed class UnreachableException : Exception;
|
||||||
|
|
||||||
|
/// <summary>Cancelling is cancellation, never a refusal — the caller's own token wins over the deadline.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task A_cancelled_token_surfaces_as_cancellation_not_as_a_refusal()
|
||||||
|
{
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
using var client = new MTConnectAgentClient(
|
||||||
|
Options(),
|
||||||
|
new StubHandler(async (_, ct) =>
|
||||||
|
{
|
||||||
|
await Task.Delay(Timeout.Infinite, ct);
|
||||||
|
|
||||||
|
throw new UnreachableException();
|
||||||
|
}));
|
||||||
|
|
||||||
|
var write = client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", cts.Token);
|
||||||
|
await cts.CancelAsync();
|
||||||
|
|
||||||
|
await Should.ThrowAsync<OperationCanceledException>(() => write);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── disposal ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Writing_through_a_disposed_client_throws_ObjectDisposedException()
|
||||||
|
{
|
||||||
|
var client = new MTConnectAgentClient(Options(), new StubHandler((_, _) => Task.FromResult(Ok())));
|
||||||
|
client.Dispose();
|
||||||
|
|
||||||
|
await Should.ThrowAsync<ObjectDisposedException>(
|
||||||
|
() => client.WriteObservationAsync("StubDevice", "dev1_msg", "hi", CancellationToken.None));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user