feat(mtconnect): warn at Test Connect and deploy when SampleCount >= the Agent bufferSize (#825)

Parse the /probe Header bufferSize; MTConnectDriverProbe reads sampleCount
(default 1000) and answers Ok with a WARNING message (amber chip in the
Test Connect button); InitializeAsync logs the same Warning and exposes
agentBufferSize on the diagnostics bag. Live-checked against the shared
cppagent fixture (bufferSize 16384).
This commit is contained in:
Joseph Doherty
2026-11-11 01:23:33 -05:00
parent 40969e5c4b
commit f8f6781a24
9 changed files with 258 additions and 5 deletions
@@ -55,6 +55,27 @@ public sealed partial class MTConnectDriver : IDriverDiagnostics
/// <summary>Times a pump raised its count. Diagnostics only.</summary>
private long _sampleCountRaises;
/// <summary>The Agent's <c>/probe</c> <c>bufferSize</c> as of the last initialize, or 0 when unknown. Diagnostics only.</summary>
private long _agentBufferSize;
/// <summary>
/// lmxopcua#825 — the configuration warning for a <see cref="MTConnectDriverOptions.SampleCount"/> the Agent
/// will refuse. An Agent answers <c>/sample?count=N</c> with <c>OUT_OF_RANGE</c> ("'count' must be less than
/// bufferSize") when <c>N ≥ bufferSize</c>, so such a driver never streams: it re-baselines from
/// <c>/current</c> and reports Degraded after three of them. Shared by <see cref="MTConnectDriverProbe"/>
/// (Test Connect) and the driver's initialize (deploy), so both say the same thing.
/// </summary>
/// <param name="sampleCount">The configured count.</param>
/// <param name="bufferSize">The Agent's buffer size, or null when its header did not say.</param>
/// <returns>The warning text, or <see langword="null"/> when the count fits (or the buffer size is unknown).</returns>
internal static string? SampleCountBufferWarning(int sampleCount, long? bufferSize) =>
bufferSize is { } size && sampleCount >= size
? $"SampleCount {sampleCount.ToString(CultureInfo.InvariantCulture)} is at or above the Agent's bufferSize "
+ $"{size.ToString(CultureInfo.InvariantCulture)}: the Agent refuses every /sample stream (OUT_OF_RANGE), so "
+ "values will only re-baseline from /current and the driver reports Degraded. Set SampleCount below "
+ $"{size.ToString(CultureInfo.InvariantCulture)}."
: null;
/// <summary>The ceiling a pump started with <paramref name="configured"/> may raise its count to.</summary>
/// <param name="configured">The configured <see cref="MTConnectDriverOptions.SampleCount"/>.</param>
/// <returns>The ceiling — never below <paramref name="configured"/>.</returns>
@@ -77,6 +98,12 @@ public sealed partial class MTConnectDriver : IDriverDiagnostics
bag["sampleLag"] = lag.ToString(CultureInfo.InvariantCulture);
}
var bufferSize = Interlocked.Read(ref _agentBufferSize);
if (bufferSize > 0)
{
bag["agentBufferSize"] = bufferSize.ToString(CultureInfo.InvariantCulture);
}
bag["sampleSaturatedChunks"] = Interlocked.Read(ref _saturatedChunks).ToString(CultureInfo.InvariantCulture);
bag["sampleCountRaises"] = Interlocked.Read(ref _sampleCountRaises).ToString(CultureInfo.InvariantCulture);
@@ -3333,6 +3333,15 @@ public sealed partial class MTConnectDriver
// ---- commit point: everything below is non-throwing bookkeeping ----
Volatile.Write(ref _options, options);
// lmxopcua#825 — a SampleCount the Agent will refuse is a configuration error; say so at deploy rather than
// leaving it to surface as three progress-free re-baselines and a Degraded stream.
Interlocked.Exchange(ref _agentBufferSize, probe.BufferSize ?? 0);
if (SampleCountBufferWarning(options.SampleCount, probe.BufferSize) is { } bufferWarning)
{
_logger.LogWarning("MTConnect driver {DriverInstanceId} ({AgentUri}): {Warning}",
_driverInstanceId, AgentDisplay(options), bufferWarning);
}
// Derived from the options AND the /probe model just fetched: a raw tag whose TagConfig
// blob declares no type takes the type the Agent itself declares for that DataItem, so a
// browse-committed tag (whose blob carries only the address) publishes as the number it
@@ -132,8 +132,18 @@ public sealed class MTConnectDriverProbe : IDriverProbe
/// driver's tag/timing configuration — see the "reachable ≠ deployable" remarks on the type.
/// </summary>
public string? DeviceName { get; set; }
/// <summary>
/// The <c>/sample</c> <c>count</c> (lmxopcua#825). Not needed to reach the Agent, but read so Test Connect
/// can warn when it is at or above the Agent's <c>bufferSize</c> — a config the Agent will refuse on every
/// stream. Absent ⇒ the driver's own default.
/// </summary>
public int? SampleCount { get; set; }
}
/// <summary>The driver's default <c>SampleCount</c> (<c>MTConnectDriver.ParseOptions</c>) when the config omits it.</summary>
private const int DefaultSampleCount = 1_000;
/// <inheritdoc />
public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
{
@@ -195,10 +205,17 @@ public sealed class MTConnectDriverProbe : IDriverProbe
try
{
using var client = new MTConnectAgentClient(options, _handler);
_ = await client.ProbeAsync(cts.Token).ConfigureAwait(false);
var model = await client.ProbeAsync(cts.Token).ConfigureAwait(false);
sw.Stop();
return new(true, $"MTConnect /probe OK ({safeUri})", sw.Elapsed);
// Reachable but misconfigured stays Ok (the endpoint answered — Test Connect's question), with the
// warning in the message so the operator sees it before deploying (lmxopcua#825).
var warning = MTConnectDriver.SampleCountBufferWarning(dto.SampleCount ?? DefaultSampleCount, model.BufferSize);
return new(true,
warning is null
? $"MTConnect /probe OK ({safeUri})"
: $"MTConnect /probe OK ({safeUri}) — WARNING: {warning}",
sw.Elapsed);
}
catch (OperationCanceledException)
{
@@ -8,7 +8,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.MTConnect;
/// <c>XElement</c>.
/// </summary>
/// <param name="Devices">Every device the Agent's probe response declares.</param>
public sealed record MTConnectProbeModel(IReadOnlyList<MTConnectDevice> Devices);
/// <param name="BufferSize">
/// The Agent's observation ring-buffer size (the <c>/probe</c> <c>Header</c>'s <c>bufferSize</c>), or
/// <see langword="null"/> when the header omits it or it is not a positive integer. A <c>/sample</c>
/// <c>count</c> at or above it is refused <c>OUT_OF_RANGE</c> by the Agent (lmxopcua#825).
/// </param>
public sealed record MTConnectProbeModel(IReadOnlyList<MTConnectDevice> Devices, long? BufferSize = null);
/// <summary>
/// Common shape shared by <see cref="MTConnectDevice"/> and <see cref="MTConnectComponent"/>:
@@ -132,7 +132,21 @@ internal static class MTConnectProbeParser
"MTConnect /probe response declares no devices; refusing to report an empty device model as a successful probe.");
}
return new MTConnectProbeModel(devices);
return new MTConnectProbeModel(devices, ReadBufferSize(root));
}
/// <summary>
/// The <c>Header</c>'s <c>bufferSize</c> (lmxopcua#825). Advisory only — a missing, malformed or non-positive
/// value is <see langword="null"/>, never a parse failure, because nothing but a configuration warning reads it.
/// </summary>
private static long? ReadBufferSize(XElement root)
{
var header = MTConnectXml.ChildrenNamed(root, "Header").FirstOrDefault();
var raw = header is null ? null : MTConnectXml.OptionalAttribute(header, "bufferSize");
return long.TryParse(raw, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var size)
&& size > 0
? size
: null;
}
private static MTConnectDevice ReadDevice(XElement element) =>