diff --git a/docs/drivers/MTConnect.md b/docs/drivers/MTConnect.md
index beeca1a4..f960bb9d 100644
--- a/docs/drivers/MTConnect.md
+++ b/docs/drivers/MTConnect.md
@@ -90,7 +90,7 @@ request/response handshake — is a different mechanism and remains deferred; se
| `IReadable` | `ReadAsync` → the live `/sample` pump's index while it is delivering and healthy, else one `/current` per call | **Not the production data path** — see below |
| `IWritable` | `WriteAsync` → `POST {agent}/{device}?{dataItemId}={value}` on the Agent's `AllowPut` surface | **Off by default** — needs `Write.Enabled` **and** the Agent's `AllowPut`; CONDITION items are refused |
| `ISubscribable` | `SubscribeAsync`/`OnDataChange` — the shared `/sample` long-poll pump | **The production data path** |
-| `IHostConnectivityProbe` | periodic `/probe` under `Probe.*` | No consumer wires it today — see [Known limitations](#known-limitations) |
+| `IHostConnectivityProbe` | periodic headers-only `/probe` ping under `Probe.*` — the status is read, the device model is neither downloaded in full nor parsed (review 2026-10-21 T-7; it used to parse the whole model every tick) | Consumed — feeds `/hosts` (see [Known limitations](#known-limitations)) |
| `IRediscoverable` | watches the Agent's `Header.instanceId` | No consumer wires it today — see [Known limitations](#known-limitations) |
| `IDriverDiagnostics` | `GetDiagnostics()` — `/sample` effective count, lag, saturated chunks, count raises | Shown on `/hosts` (review 2026-10-21 T-5) |
| `IAlarmSource` | CONDITION observations off the same `/sample` pump → Part 9 transitions keyed by the alarm tag's RawPath | Opt-in per raw tag (`alarm` object) — see [CONDITION alarms](#condition-alarms-626) |
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/IMTConnectAgentClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/IMTConnectAgentClient.cs
index f5d9cf03..76f46b7c 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/IMTConnectAgentClient.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/IMTConnectAgentClient.cs
@@ -76,6 +76,17 @@ public interface IMTConnectAgentClient : IDisposable
/// The parsed device hierarchy.
Task ProbeAsync(CancellationToken ct);
+ ///
+ /// A connectivity check: does the Agent answer /probe with a success status? Reads the response
+ /// headers only — the device model is neither downloaded in full nor parsed (R06:T-7: the connectivity
+ /// probe used to parse the whole /probe document every tick and throw it away). Throws exactly when
+ /// would for an unreachable or refusing Agent. The default delegates to
+ /// and discards the model.
+ ///
+ /// Cancellation token (the caller's deadline).
+ /// A task completing when the Agent has answered with a success status.
+ async Task PingAsync(CancellationToken ct) => _ = await ProbeAsync(ct).ConfigureAwait(false);
+
///
/// Issues an Agent /current request and returns the latest observed value for every
/// data item. Used both to prime the observation index at startup and to re-baseline after
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectAgentClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectAgentClient.cs
index 0d8dea7c..ab946fcb 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectAgentClient.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectAgentClient.cs
@@ -182,6 +182,35 @@ public sealed class MTConnectAgentClient : IMTConnectAgentClient, IDisposable
return MTConnectProbeParser.Parse(body);
}
+ ///
+ public async Task PingAsync(CancellationToken ct)
+ {
+ ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposed) != 0, this);
+
+ using var lifetime = CancellationTokenSource.CreateLinkedTokenSource(ct, _disposeCts.Token);
+ using var deadline = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);
+ deadline.CancelAfter(_requestTimeout);
+
+ using var request = new HttpRequestMessage(HttpMethod.Get, _probeUri);
+ HttpResponseMessage response;
+ try
+ {
+ // ResponseHeadersRead: the status line is the whole answer this needs. The body (the device model —
+ // the largest document the Agent serves) is never buffered or parsed; disposing the response drops it.
+ response = await _http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, deadline.Token)
+ .ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (!lifetime.IsCancellationRequested)
+ {
+ throw TimedOut(_probeUri);
+ }
+
+ using (response)
+ {
+ response.EnsureSuccessStatusCode();
+ }
+ }
+
///
public async Task CurrentAsync(CancellationToken ct)
{
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs
index ec469619..ae1ac7bf 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/MTConnectDriver.cs
@@ -1632,9 +1632,9 @@ public sealed partial class MTConnectDriver
/// free, but it only exists while something is subscribed — an instance with no
/// subscriptions would report indefinitely, and host
/// reachability would become a function of OPC UA client behaviour. A dedicated tick is
- /// the one signal that is true whether or not anyone is watching. Its cost is a parse of
- /// the device model per interval, which is why
- /// exists.
+ /// the one signal that is true whether or not anyone is watching. Its cost used to be a parse
+ /// of the whole device model per interval; it is now one request whose response headers are
+ /// read and whose body is dropped (, R06:T-7).
///
///
/// It waits first, then probes. has just completed a
@@ -1674,8 +1674,8 @@ public sealed partial class MTConnectDriver
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(ct);
deadline.CancelAfter(options.Probe.Timeout);
- // The answer is deliberately discarded — see the remarks.
- _ = await client.ProbeAsync(deadline.Token).ConfigureAwait(false);
+ // Headers only — the device model is neither downloaded in full nor parsed (R06:T-7).
+ await client.PingAsync(deadline.Token).ConfigureAwait(false);
reachable = true;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CannedAgentClient.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CannedAgentClient.cs
index 1a80e651..164457a8 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CannedAgentClient.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/CannedAgentClient.cs
@@ -272,6 +272,22 @@ internal sealed class CannedAgentClient : IMTConnectAgentClient, IDisposable
return answer;
}
+ /// Number of connectivity pings (headers-only /probe) issued against this client.
+ public int PingCallCount => Volatile.Read(ref _pingCallCount);
+
+ private int _pingCallCount;
+
+ ///
+ /// Same failure surface as (), counted separately.
+ public Task PingAsync(CancellationToken ct)
+ {
+ ObjectDisposedException.ThrowIf(IsDisposed, this);
+ ct.ThrowIfCancellationRequested();
+ Interlocked.Increment(ref _pingCallCount);
+
+ return ProbeFailure is null ? Task.CompletedTask : throw ProbeFailure;
+ }
+
///
public async Task CurrentAsync(CancellationToken ct)
{
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectHostAndRediscoverTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectHostAndRediscoverTests.cs
index fc96b5c3..46caf10d 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectHostAndRediscoverTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectHostAndRediscoverTests.cs
@@ -385,19 +385,22 @@ public sealed class MTConnectHostAndRediscoverTests
{
var (_, client, ticker) = await ProbingDriverAsync();
- // Parked before the first tick: the loop waits, THEN probes, so initialize's own /probe is
- // the only call so far.
+ // Parked before the first tick: the loop waits, THEN probes, so nothing has been pinged yet.
+ client.PingCallCount.ShouldBe(0);
+
+ await TickAsync(ticker);
+ client.PingCallCount.ShouldBe(1);
+
+ await TickAsync(ticker);
+ client.PingCallCount.ShouldBe(2);
+
+ await TickAsync(ticker);
+ client.PingCallCount.ShouldBe(3);
+
+ // Review 2026-10-21 T-7: a tick is a headers-only ping — the full /probe (download + parse of the device
+ // model) is still initialize's one call, never a tick's.
client.ProbeCallCount.ShouldBe(1);
- await TickAsync(ticker);
- client.ProbeCallCount.ShouldBe(2);
-
- await TickAsync(ticker);
- client.ProbeCallCount.ShouldBe(3);
-
- await TickAsync(ticker);
- client.ProbeCallCount.ShouldBe(4);
-
// …and it waits the authored interval between them, rather than a hard-coded cadence.
ticker.LastInterval.ShouldBe(AuthoredInterval);
}
@@ -535,11 +538,12 @@ public sealed class MTConnectHostAndRediscoverTests
// The loop is provably gone (ShutdownAsync awaited it), so releasing another tick can reach
// nobody — no probe against a disposed client, and no further waits on the scheduler.
- var calls = client.ProbeCallCount;
+ var calls = client.PingCallCount;
+ calls.ShouldBeGreaterThan(0, "positive control: the tick before shutdown did reach the Agent");
var waits = ticker.Waits;
ticker.Tick();
- client.ProbeCallCount.ShouldBe(calls);
+ client.PingCallCount.ShouldBe(calls);
ticker.Waits.ShouldBe(waits);
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectStreamsParseTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectStreamsParseTests.cs
index 60f41b44..eba125ea 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectStreamsParseTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Tests/MTConnectStreamsParseTests.cs
@@ -572,6 +572,32 @@ public sealed class MTConnectStreamsParseTests
await Should.ThrowAsync(async () => await client.CurrentAsync(Ct));
}
+ // -------------------------------------------------------------- MTConnectAgentClient.PingAsync
+
+ /// R06:T-7 — the ping reads the status only: a body that is not a device model does not matter.
+ [Fact]
+ public async Task PingAsync_succeeds_on_a_success_status_without_parsing_the_body()
+ {
+ var handler = StubHandler.RespondingWith("this is not an MTConnectDevices document");
+ using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });
+
+ await client.PingAsync(Ct);
+
+ handler.LastRequestUri!.AbsoluteUri.ShouldBe("http://agent:5000/probe");
+ // Positive control: the full probe DOES parse that body, and refuses it.
+ await Should.ThrowAsync(async () => await client.ProbeAsync(Ct));
+ }
+
+ /// A non-success status is an unreachable / refusing Agent, exactly as for the full probe.
+ [Fact]
+ public async Task PingAsync_throws_on_a_non_success_status()
+ {
+ var handler = StubHandler.RespondingWith("nope", HttpStatusCode.ServiceUnavailable);
+ using var client = NewClient(handler, new MTConnectDriverOptions { AgentUri = "http://agent:5000" });
+
+ await Should.ThrowAsync(async () => await client.PingAsync(Ct));
+ }
+
// -------------------------------------------------------------- MTConnectAgentClient.SampleAsync
[Fact]