diff --git a/ZB.MOM.WW.OtOpcUa.slnx b/ZB.MOM.WW.OtOpcUa.slnx
index 020a95af..6e6ccab6 100644
--- a/ZB.MOM.WW.OtOpcUa.slnx
+++ b/ZB.MOM.WW.OtOpcUa.slnx
@@ -105,6 +105,7 @@
+
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttDriverOptions.cs
new file mode 100644
index 00000000..4fe77477
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttDriverOptions.cs
@@ -0,0 +1,152 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
+
+///
+/// MQTT / Sparkplug B driver configuration. Bound from DriverConfig JSON at
+/// driver-host registration time. Models the settings documented in
+/// docs/plans/2026-07-15-mqtt-sparkplug-driver-design.md §5.1.
+///
+///
+/// A record (not a plain class) so a future secret-resolution seam can produce a
+/// credential-resolved copy with a with expression — mirrors
+/// OpcUaClientDriverOptions. selects the ingest shape;
+/// and are nullable sub-objects — only the one
+/// matching is populated.
+///
+public sealed record MqttDriverOptions
+{
+ /// Broker hostname or IP address.
+ public string Host { get; init; } = "localhost";
+
+ /// Broker TCP port.
+ [Range(1, 65535)]
+ public int Port { get; init; } = 8883;
+
+ ///
+ /// MQTT client identifier sent at CONNECT. Leave unset to let the driver generate one
+ /// (a stable per-instance id is recommended so broker-side ACLs / session state persist
+ /// across reconnects).
+ ///
+ public string? ClientId { get; init; }
+
+ ///
+ /// When true, connect over TLS. Default true — this driver never ships an
+ /// anonymous/plaintext-by-default posture; a deployment must opt into false for an
+ /// on-prem/dev broker with no TLS listener.
+ ///
+ public bool UseTls { get; init; } = true;
+
+ ///
+ /// When true, accept any self-signed / untrusted broker certificate. Dev/on-prem
+ /// escape hatch only — mirrors the ServerHistorian TLS knobs. Must stay
+ /// false in production so MITM attacks against the broker connection fail closed.
+ ///
+ public bool AllowUntrustedServerCertificate { get; init; } = false;
+
+ ///
+ /// PEM CA file pinning the broker's TLS chain. null/empty uses the OS trust
+ /// store.
+ ///
+ public string? CaCertificatePath { get; init; }
+
+ /// Username for broker authentication. null connects without a username.
+ public string? Username { get; init; }
+
+ ///
+ /// Password for broker authentication. Blank in committed JSON — supply via env, never
+ /// commit or log.
+ ///
+ public string? Password { get; init; } = "";
+
+ /// MQTT protocol version to negotiate at CONNECT.
+ public MqttProtocolVersion ProtocolVersion { get; init; } = MqttProtocolVersion.V500;
+
+ /// Whether to request a clean session (v3.1.1) / clean start (v5.0) at CONNECT.
+ public bool CleanSession { get; init; } = true;
+
+ /// Keep-alive interval sent at CONNECT.
+ [Range(1, int.MaxValue)]
+ public int KeepAliveSeconds { get; init; } = 30;
+
+ /// Bounded connect deadline (see design §8) — the driver never hangs past this.
+ [Range(1, int.MaxValue)]
+ public int ConnectTimeoutSeconds { get; init; } = 15;
+
+ /// Initial reconnect backoff after a connection drop.
+ [Range(1, int.MaxValue)]
+ public int ReconnectMinBackoffSeconds { get; init; } = 1;
+
+ /// Cap on the exponential reconnect backoff.
+ [Range(1, int.MaxValue)]
+ public int ReconnectMaxBackoffSeconds { get; init; } = 30;
+
+ /// Selects the ingest shape — Plain topics or Sparkplug B.
+ public MqttMode Mode { get; init; } = MqttMode.Plain;
+
+ ///
+ /// Sparkplug-only settings. Populated when is
+ /// ; null in Plain mode.
+ ///
+ public MqttSparkplugOptions? Sparkplug { get; init; }
+
+ ///
+ /// Plain-mode-only settings. Populated when is
+ /// ; null in Sparkplug B mode.
+ ///
+ public MqttPlainOptions? Plain { get; init; }
+}
+
+///
+/// Sparkplug B settings for an instance in
+/// mode. See
+/// docs/plans/2026-07-15-mqtt-sparkplug-driver-design.md §5.1.
+///
+public sealed record MqttSparkplugOptions
+{
+ /// Sparkplug group id — the driver subscribes spBv1.0/{GroupId}/#.
+ public string GroupId { get; init; } = "";
+
+ ///
+ /// Sparkplug Host Application identity — published as spBv1.0/STATE/{HostId}
+ /// (Sparkplug v3.0).
+ ///
+ public string HostId { get; init; } = "";
+
+ ///
+ /// When true, this driver instance acts as the Sparkplug Primary Host
+ /// Application. At most one primary host may exist per host-id per broker.
+ ///
+ public bool ActAsPrimaryHost { get; init; } = false;
+
+ ///
+ /// When true, a detected sequence-number gap triggers a Sparkplug rebirth
+ /// request (NCMD) rather than silently continuing with stale metric state.
+ ///
+ public bool RequestRebirthOnGap { get; init; } = true;
+
+ ///
+ /// How long, in seconds, browse/discovery collects NBIRTH/DBIRTH traffic before
+ /// considering the observed metric set stable.
+ ///
+ [Range(1, int.MaxValue)]
+ public int BirthObservationWindowSeconds { get; init; } = 15;
+}
+
+///
+/// Plain-MQTT-mode settings for an instance in
+/// mode. See
+/// docs/plans/2026-07-15-mqtt-sparkplug-driver-design.md §5.1.
+///
+public sealed record MqttPlainOptions
+{
+ ///
+ /// Optional prefix prepended when the driver needs to compose a topic (e.g. discovery
+ /// scoping); per-tag topics are authored explicitly and are unaffected.
+ ///
+ public string TopicPrefix { get; init; } = "";
+
+ /// Default QoS used when a tag's own qos is unset.
+ [Range(0, 2)]
+ public int DefaultQos { get; init; } = 1;
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttMode.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttMode.cs
new file mode 100644
index 00000000..86f907c7
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttMode.cs
@@ -0,0 +1,14 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
+
+///
+/// Selects the ingest shape an instance uses. See
+/// docs/plans/2026-07-15-mqtt-sparkplug-driver-design.md §5.1.
+///
+public enum MqttMode
+{
+ /// Plain MQTT — the driver subscribes to authored topics directly.
+ Plain,
+
+ /// Sparkplug B — the driver decodes Tahu-encoded NBIRTH/DBIRTH/NDATA/DDATA payloads.
+ SparkplugB,
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttPayloadFormat.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttPayloadFormat.cs
new file mode 100644
index 00000000..c8ff9fa0
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttPayloadFormat.cs
@@ -0,0 +1,17 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
+
+///
+/// How a Plain-mode MQTT tag's payload is decoded. See
+/// docs/plans/2026-07-15-mqtt-sparkplug-driver-design.md §5.3.
+///
+public enum MqttPayloadFormat
+{
+ /// The payload is a JSON document; a JSONPath selects the value (jsonPath).
+ Json,
+
+ /// The payload bytes are used as-is (e.g. binary / opaque).
+ Raw,
+
+ /// The payload is a bare scalar string (e.g. "23.5"), parsed by dataType.
+ Scalar,
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttProtocolVersion.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttProtocolVersion.cs
new file mode 100644
index 00000000..8a674e34
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/MqttProtocolVersion.cs
@@ -0,0 +1,14 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
+
+///
+/// MQTT wire protocol version negotiated at CONNECT. See
+/// docs/plans/2026-07-15-mqtt-sparkplug-driver-design.md §5.1.
+///
+public enum MqttProtocolVersion
+{
+ /// MQTT 3.1.1.
+ V311,
+
+ /// MQTT 5.0.
+ V500,
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj
index 3645fe89..6c5a5492 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj
@@ -6,9 +6,6 @@
true
-
-
+
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverOptionsTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverOptionsTests.cs
new file mode 100644
index 00000000..29b6317f
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/MqttDriverOptionsTests.cs
@@ -0,0 +1,39 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.Mqtt;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
+
+public sealed class MqttDriverOptionsTests
+{
+ private static readonly JsonSerializerOptions J = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
+ Converters = { new JsonStringEnumConverter() },
+ };
+
+ [Fact]
+ public void Deserialize_SparkplugConfig_ReadsModeAndSubObject()
+ {
+ const string json = """
+ { "host":"10.100.0.35","port":8883,"useTls":true,"mode":"SparkplugB",
+ "sparkplug":{"groupId":"Plant1","hostId":"h1","requestRebirthOnGap":true} }
+ """;
+ var o = JsonSerializer.Deserialize(json, J)!;
+ o.Mode.ShouldBe(MqttMode.SparkplugB);
+ o.UseTls.ShouldBeTrue();
+ o.Sparkplug!.GroupId.ShouldBe("Plant1");
+ o.Plain.ShouldBeNull();
+ }
+
+ [Fact]
+ public void Serialize_WritesEnumsAsNames_NotOrdinals()
+ {
+ var s = JsonSerializer.Serialize(new MqttDriverOptions { Mode = MqttMode.Plain }, J);
+ s.ShouldContain("\"Plain\"");
+ s.ShouldNotContain("\"mode\":0");
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests.csproj b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests.csproj
new file mode 100644
index 00000000..65591a1b
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+ true
+ ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
+