diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriverProbe.cs
new file mode 100644
index 00000000..ce8a71cc
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/MqttDriverProbe.cs
@@ -0,0 +1,244 @@
+using System.Diagnostics;
+using System.Net.Sockets;
+using System.Security.Authentication;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Microsoft.Extensions.Logging.Abstractions;
+using MQTTnet;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
+
+///
+/// CONNECT-handshake probe for the -shaped driver config.
+/// Opens a bounded MQTT CONNECT against the configured broker; a CONNACK-accepted response
+/// (MqttClientConnectResultCode.Success) is the "device is answering" proof (green +
+/// latency). Refused/TLS/auth/timeout failures each surface a targeted message. Mirrors
+/// ModbusDriverProbe's shape.
+///
+///
+///
+/// Reuses for the connect (TLS,
+/// CA-pin, credentials, protocol-version mapping) rather than hand-rolling a second
+/// connect path — a duplicate would be a security divergence. A distinct client-id suffix
+/// (see ) keeps a probe from colliding with the driver's
+/// own session: an MQTT broker disconnects an existing client when a new one CONNECTs
+/// with the same client id, so a probe that reused the driver's id would knock the
+/// running driver offline every time an operator clicked "Test connect".
+///
+///
+/// A broker-rejected CONNACK is not a thrown exception. Live-probed against
+/// MQTTnet 5.2.0.1603: IMqttClient.ConnectAsync returns a
+/// MqttClientConnectResult whose ResultCode carries the broker's CONNACK
+/// reason (e.g. NotAuthorized, BadUserNameOrPassword) — it does
+/// not throw for a rejected CONNACK. Only transport-level failures throw, and
+/// their shape varies by where the failure occurs: a closed/refused port throws
+/// MqttCommunicationException wrapping a ; an
+/// untrusted broker certificate throws MqttCommunicationException wrapping an
+/// ; a broker that accepts the TCP handshake and
+/// then never answers CONNACK throws either MqttConnectingFailedException
+/// (wrapping "Connection closed") or a bare
+/// ("MQTT connect canceled"), inconsistently, depending on whether MQTTnet's own
+/// internal Timeout or the deadline token wins the race. Consequently this probe —
+/// like — never switches on exception type to
+/// detect a timeout; it checks the deadline token itself, which is authoritative
+/// regardless of which exception shape MQTTnet happened to throw.
+///
+///
+public sealed class MqttDriverProbe : IDriverProbe
+{
+ ///
+ /// Marks the transient probe identity in the broker's client-id/session logs — mirrors the
+ /// browser's own -browse-{guid8} suffix so the two transient sessions are
+ /// distinguishable in broker-side logs, and so a probe can never collide with (and knock
+ /// offline) the driver's own live session.
+ ///
+ internal const string ProbeClientIdPrefix = "-probe-";
+
+ ///
+ /// Shared JSON options for every MQTT config-parsing seam in this driver: enums
+ /// (, , protocolVersion)
+ /// round-trip by name, never ordinal — the repo's documented enum-serialization
+ /// trap (AdminUI-authored configs with numeric enum fields fault the driver). Task 9's
+ /// MqttDriverFactoryExtensions is expected to converge on this same instance
+ /// rather than defining its own.
+ ///
+ internal static readonly JsonSerializerOptions JsonOpts = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
+ Converters = { new JsonStringEnumConverter() },
+ };
+
+ ///
+ // Literal rather than a constant: DriverTypeNames.Mqtt does not exist yet — Task 9 adds it, and
+ // owns that file. The string must stay EXACTLY "Mqtt": a DriverType string that drifts from the
+ // persisted one silently breaks driver dispatch (the repo's ModbusTcp/Modbus incident).
+ public string DriverType => "Mqtt";
+
+ ///
+ public async Task ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
+ {
+ MqttDriverOptions? options;
+ try
+ {
+ options = JsonSerializer.Deserialize(configJson, JsonOpts);
+ }
+ catch (Exception ex)
+ {
+ return new DriverProbeResult(false, $"Config JSON is invalid: {ex.Message}", null);
+ }
+
+ if (options is null)
+ {
+ return new DriverProbeResult(false, "Config JSON deserialized to null.", null);
+ }
+
+ if (string.IsNullOrWhiteSpace(options.Host) || options.Port is <= 0 or > 65535)
+ {
+ return new DriverProbeResult(false, "Config has no host/port to probe.", null);
+ }
+
+ // Honour the config's own connectTimeoutSeconds (factory parity, mirrors
+ // ModbusDriverProbe) — the caller's timeout is the fallback when the config omits it.
+ var effectiveSeconds = options.ConnectTimeoutSeconds > 0
+ ? options.ConnectTimeoutSeconds
+ : (int)Math.Ceiling(timeout.TotalSeconds);
+ if (effectiveSeconds <= 0)
+ {
+ effectiveSeconds = 1;
+ }
+
+ // Rebuild with the resolved timeout so BuildClientOptions' own WithTimeout(...) and this
+ // method's deadline agree — both must expire at the same instant for the deadline check
+ // below to be a reliable signal regardless of which of the two actually threw.
+ var probeOptions = options with { ConnectTimeoutSeconds = effectiveSeconds };
+
+ MqttClientOptions clientOptions;
+ try
+ {
+ clientOptions = MqttConnection.BuildClientOptions(
+ probeOptions,
+ ProbeClientIdPrefix + Guid.NewGuid().ToString("N")[..8],
+ NullLogger.Instance);
+ }
+ catch (Exception ex)
+ {
+ // Never let a configured Password reach the message — BuildClientOptions itself never
+ // logs it, and no exception path through it can format credential values.
+ return new DriverProbeResult(false, $"Config could not be used to build a connection: {ex.Message}", null);
+ }
+
+ var target = $"{probeOptions.Host}:{probeOptions.Port}";
+ var sw = Stopwatch.StartNew();
+
+ using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(effectiveSeconds));
+ using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, deadline.Token);
+
+ using var client = new MqttClientFactory().CreateMqttClient();
+
+ MqttClientConnectResult result;
+ try
+ {
+ result = await client.ConnectAsync(clientOptions, linked.Token).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ return new DriverProbeResult(false, Classify(target, ex, ct, deadline, effectiveSeconds), null);
+ }
+
+ try
+ {
+ if (result.ResultCode != MqttClientConnectResultCode.Success)
+ {
+ return new DriverProbeResult(false, ClassifyRejectedConnack(target, result.ResultCode), null);
+ }
+
+ sw.Stop();
+ return new DriverProbeResult(true, "MQTT CONNECT OK", sw.Elapsed);
+ }
+ finally
+ {
+ await DisconnectBestEffortAsync(client, effectiveSeconds).ConfigureAwait(false);
+ }
+ }
+
+ ///
+ /// Maps a broker-rejected CONNACK ( not
+ /// Success — never an exception, see the type remarks) to a targeted, credential-free
+ /// message.
+ ///
+ private static string ClassifyRejectedConnack(string target, MqttClientConnectResultCode code) => code switch
+ {
+ MqttClientConnectResultCode.NotAuthorized or MqttClientConnectResultCode.BadUserNameOrPassword
+ => $"Broker at {target} rejected the credentials ({code}).",
+ MqttClientConnectResultCode.UnsupportedProtocolVersion
+ => $"Broker at {target} does not support the configured protocol version ({code}).",
+ MqttClientConnectResultCode.Banned
+ => $"Broker at {target} has banned this client ({code}).",
+ _ => $"Broker at {target} refused CONNECT: {code}.",
+ };
+
+ ///
+ /// Classifies a thrown connect failure. Precedence mirrors
+ /// : caller cancellation, then the deadline, then the
+ /// exception's own shape — the deadline check is authoritative regardless of which exception
+ /// type MQTTnet happened to throw (see the type remarks for why exception type alone is not
+ /// reliable here).
+ ///
+ private static string Classify(
+ string target,
+ Exception ex,
+ CancellationToken callerToken,
+ CancellationTokenSource deadline,
+ int effectiveSeconds)
+ {
+ if (callerToken.IsCancellationRequested)
+ {
+ return "Probe was cancelled.";
+ }
+
+ if (deadline.IsCancellationRequested)
+ {
+ return $"Probe timed out after {effectiveSeconds}s.";
+ }
+
+ // Walk to the root cause — MQTTnet wraps transport/TLS failures one or two levels deep.
+ var root = ex;
+ while (root.InnerException is not null)
+ {
+ root = root.InnerException;
+ }
+
+ return root switch
+ {
+ SocketException se => $"Connect to {target} failed: {se.SocketErrorCode}",
+ AuthenticationException => $"TLS handshake with {target} failed: {root.Message}",
+ _ => $"Connect to {target} failed: {root.Message}",
+ };
+ }
+
+ ///
+ /// Best-effort teardown of a session this probe established. Failure here is not the
+ /// probe's business — the CONNECT outcome already decided Ok/failure — and it must never be
+ /// allowed to hang past the connect budget.
+ ///
+ private static async Task DisconnectBestEffortAsync(IMqttClient client, int timeoutSeconds)
+ {
+ if (!client.IsConnected)
+ {
+ return;
+ }
+
+ try
+ {
+ using var disconnectDeadline = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds));
+ await client.DisconnectAsync(new MqttClientDisconnectOptions(), disconnectDeadline.Token)
+ .ConfigureAwait(false);
+ }
+ catch
+ {
+ // Best-effort only.
+ }
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverProbeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverProbeTests.cs
new file mode 100644
index 00000000..14c70eab
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverProbeTests.cs
@@ -0,0 +1,182 @@
+using System.Diagnostics;
+using System.Net;
+using System.Net.Sockets;
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
+
+///
+/// Tests for . Mirrors ModbusDriverProbeTests'
+/// shape: a real loopback listener drives server-side behaviour so the probe's CONNECT
+/// handshake is exercised against something real rather than mocked.
+///
+[Trait("Category", "Unit")]
+public sealed class MqttDriverProbeTests
+{
+ private static readonly TimeSpan QuickTimeout = TimeSpan.FromSeconds(3);
+
+ /// A loopback listener that accepts the TCP connection and then never answers CONNACK —
+ /// the shape that actually wedges a client. A refused port fails instantly with ECONNREFUSED and
+ /// would pass whether or not the probe's deadline logic works at all, so the timeout test below
+ /// needs this rather than a closed port.
+ private sealed class BlackholeBroker : IDisposable
+ {
+ private readonly TcpListener _listener;
+ private readonly CancellationTokenSource _cts = new();
+ private readonly List _accepted = [];
+
+ public BlackholeBroker()
+ {
+ _listener = new TcpListener(IPAddress.Loopback, 0);
+ _listener.Start();
+ Port = ((IPEndPoint)_listener.LocalEndpoint).Port;
+ _ = Task.Run(AcceptLoopAsync);
+ }
+
+ public int Port { get; }
+
+ private async Task AcceptLoopAsync()
+ {
+ try
+ {
+ while (!_cts.IsCancellationRequested)
+ {
+ var client = await _listener.AcceptTcpClientAsync(_cts.Token).ConfigureAwait(false);
+ lock (_accepted)
+ {
+ _accepted.Add(client);
+ }
+ // Accept the TCP handshake and then say nothing — never a CONNACK.
+ }
+ }
+ catch (Exception)
+ {
+ // Listener stopped / cancelled — expected at teardown.
+ }
+ }
+
+ public void Dispose()
+ {
+ _cts.Cancel();
+ _listener.Stop();
+ lock (_accepted)
+ {
+ foreach (var client in _accepted)
+ {
+ client.Dispose();
+ }
+ }
+ _cts.Dispose();
+ }
+ }
+
+ private static TcpListener StartListener()
+ {
+ var l = new TcpListener(IPAddress.Loopback, 0);
+ l.Start();
+ return l;
+ }
+
+ private static int ListenerPort(TcpListener l) => ((IPEndPoint)l.LocalEndpoint).Port;
+
+ [Fact]
+ public void DriverType_IsCanonicalMqtt() => new MqttDriverProbe().DriverType.ShouldBe("Mqtt");
+
+ /// The plan's own vacuous-pass trap: a closed port fails instantly (ECONNREFUSED)
+ /// regardless of whether the deadline logic works. Kept as the "refused" leg, not the
+ /// "deadline" leg — see
+ /// for the real timeout-path exercise.
+ [Fact]
+ public async Task ProbeAsync_DeadBroker_ReturnsFailedResult_WithinDeadline()
+ {
+ var listener = StartListener();
+ var port = ListenerPort(listener);
+ listener.Stop();
+
+ var r = await new MqttDriverProbe().ProbeAsync(
+ $$"""{"host":"127.0.0.1","port":{{port}},"useTls":false,"connectTimeoutSeconds":2}""",
+ QuickTimeout,
+ CancellationToken.None);
+
+ r.Ok.ShouldBeFalse();
+ r.Message.ShouldNotBeNullOrEmpty();
+ }
+
+ /// The real timeout-path exercise: a broker that completes the TCP handshake and then
+ /// never answers CONNACK must still fail at the configured deadline rather than hang.
+ [Fact]
+ public async Task ProbeAsync_BlackholeBroker_FailsWithinDeadline_NoHang()
+ {
+ using var blackhole = new BlackholeBroker();
+ var config = $$"""{"host":"127.0.0.1","port":{{blackhole.Port}},"useTls":false,"connectTimeoutSeconds":1}""";
+
+ var sw = Stopwatch.StartNew();
+ var r = await new MqttDriverProbe().ProbeAsync(config, QuickTimeout, CancellationToken.None);
+ sw.Stop();
+
+ r.Ok.ShouldBeFalse();
+ r.Message.ShouldNotBeNullOrEmpty();
+ r.Message.ShouldContain("timed out", Case.Insensitive);
+ sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(10));
+ }
+
+ [Fact]
+ public async Task ProbeAsync_InvalidJson_ReturnsFailedResult_NotThrown()
+ {
+ var r = await new MqttDriverProbe().ProbeAsync("not valid json {{", QuickTimeout, CancellationToken.None);
+
+ r.Ok.ShouldBeFalse();
+ r.Message.ShouldNotBeNullOrEmpty();
+ }
+
+ [Fact]
+ public async Task ProbeAsync_NoHost_ReturnsFailedResult()
+ {
+ var r = await new MqttDriverProbe().ProbeAsync(
+ """{"host":"","port":0}""", QuickTimeout, CancellationToken.None);
+
+ r.Ok.ShouldBeFalse();
+ r.Message.ShouldNotBeNullOrEmpty();
+ }
+
+ /// Never leak a configured password into the probe's result message — it surfaces
+ /// directly in the AdminUI Test-connect UI.
+ [Fact]
+ public async Task ProbeAsync_NeverLeaksPasswordIntoMessage()
+ {
+ var listener = StartListener();
+ var port = ListenerPort(listener);
+ listener.Stop();
+
+ const string secret = "S3cr3tSquirrel!!";
+ var config = $$"""
+ {"host":"127.0.0.1","port":{{port}},"useTls":false,"username":"probe-user","password":"{{secret}}","connectTimeoutSeconds":2}
+ """;
+
+ var r = await new MqttDriverProbe().ProbeAsync(config, QuickTimeout, CancellationToken.None);
+
+ r.Ok.ShouldBeFalse();
+ r.Message.ShouldNotBeNullOrEmpty();
+ r.Message.ShouldNotContain(secret);
+ }
+
+ /// Enum fields (protocolVersion) must round-trip by name, per the repo's documented
+ /// enum-serialization trap — a numeric-only seam faults AdminUI-authored configs.
+ [Fact]
+ public async Task ProbeAsync_EnumAsName_DoesNotThrow()
+ {
+ var listener = StartListener();
+ var port = ListenerPort(listener);
+ listener.Stop();
+
+ var config = $$"""
+ {"host":"127.0.0.1","port":{{port}},"useTls":false,"protocolVersion":"V500","connectTimeoutSeconds":2}
+ """;
+
+ var r = await new MqttDriverProbe().ProbeAsync(config, QuickTimeout, CancellationToken.None);
+
+ r.Ok.ShouldBeFalse();
+ r.Message.ShouldNotBeNullOrEmpty();
+ }
+}