
Fully-featured MQTT 5.0 client with complete packet/property support, QoS 0–2 state machine, TLS/WebSocket transports, asynchronous APIs with reactive message streams, immutable payloads, minimal dependencies and spec validation.
A fully-featured MQTT 5.0 and 3.1.1 client library for Kotlin Multiplatform — connecting JVM, Android, iOS, macOS, Linux, Windows, and browsers through a single, idiomatic Kotlin API.
The bundled Compose Multiplatform sample app, live on tls://mqtt.meshtastic.org:8883.
suspend functions and Flow-based message deliveryByteString payloads, validated inputs, data class modelsMqttLogger interface with level filtering| MQTTastic | Typical alternatives | |
|---|---|---|
| Pure KMP | Single codebase, single API across all platforms | Wrappers around platform SDKs (Paho, Mosquitto) |
| MQTT 5.0 first | Built from the ground up for 5.0 — not retrofitted from 3.1.1 | Bolt-on 5.0 support with incomplete property coverage |
| Coroutines-native |
suspend functions and Flow everywhere — no callbacks, no blocking |
Callback-heavy APIs requiring manual coroutine bridging |
| Zero platform deps | Only Ktor + kotlinx-coroutines + kotlinx-io | Bundles native C libraries or platform-specific SDKs |
| Immutable & validated |
ByteString payloads, validated topic filters, range-checked properties |
Mutable byte arrays, silent truncation, unchecked inputs |
| Platform | Target | Transport | Status |
|---|---|---|---|
| JVM | jvm |
TCP/TLS, WebSocket | ✅ |
| Android | android |
TCP/TLS, WebSocket | ✅ |
| iOS |
iosArm64, iosSimulatorArm64
|
TCP/TLS, WebSocket | ✅ |
| macOS | macosArm64 |
TCP/TLS, WebSocket | ✅ |
| Linux |
linuxX64, linuxArm64
|
TCP/TLS, WebSocket | ✅ |
| Windows | mingwX64 |
TCP/TLS, WebSocket | ✅ |
| Browser | wasmJs |
WebSocket | ✅ |
All protocol logic — packet encoding/decoding, the client state machine, QoS flows, and property handling — lives in the mqtt-client-core module as pure commonMain Kotlin, with zero transport dependencies. Each transport ships as its own artifact, so a consumer pulls in only what it uses. Every bug fix, feature, and optimization in core applies to all 9 targets simultaneously.
┌─────────────────────────────────────────────┐
│ mqtt-client-core │ ← public API: suspend + Flow
│ MqttClient / MqttConnection / QoS machines │ ← protocol logic, keepalive
│ MqttPacket / Encoder / Decoder │ ← MQTT 5.0 wire format
│ MqttTransport / MqttTransportFactory (SPI) │ ← the transport seam
└───────────────────────┬─────────────────────┘
▲ │ api(core) ▲
│ ▼ │
┌───────────┴───────────┐ ┌───────────────────┴───────────┐
│ mqtt-client- │ │ mqtt-client-transport-ws │
│ transport-tcp │ │ WebSocketTransport(Factory) │
│ TcpTransport(Factory) │ │ ktor-client-websockets │
│ ktor-network + TLS │ │ all targets incl. browser │
│ (no browser) │ │ │
└───────────────────────┘ └────────────────────────────────┘
MqttTransport / MqttTransportFactory are the public service-provider interface — the sole platform abstraction boundary. Core has no compile-time dependency on any transport module; you supply a factory (TcpTransportFactory, WebSocketTransportFactory, or both combined with +) via MqttConfig.Builder.transportFactory. Coroutines drive everything: suspend functions for operations, SharedFlow<MqttMessage> for incoming messages, and StateFlow<ConnectionState> for lifecycle observation.
Artifacts are published to Maven Central under the org.meshtastic group. Depend on
mqtt-client-core plus the transport(s) you need. The mqtt-client-bom pins every module to one
version so you don't repeat it:
// settings.gradle.kts
repositories {
mavenCentral()
}
// build.gradle.kts
kotlin {
sourceSets {
commonMain.dependencies {
implementation(platform("org.meshtastic:mqtt-client-bom:0.5.0"))
implementation("org.meshtastic:mqtt-client-core")
// Pick the transport(s) you actually use:
implementation("org.meshtastic:mqtt-client-transport-tcp") // TCP/TLS — every target except browser
implementation("org.meshtastic:mqtt-client-transport-ws") // WebSocket — every target incl. browser
}
}
}Then supply the matching factory when building the client (combine with + if you use both):
val client = MqttClient("my-client") {
transportFactory = TcpTransportFactory() + WebSocketTransportFactory()
}Browser (wasmJs) can only use
mqtt-client-transport-ws— raw TCP is unavailable there.
// build.gradle
kotlin {
sourceSets {
commonMain {
dependencies {
implementation platform('org.meshtastic:mqtt-client-bom:0.5.0')
implementation 'org.meshtastic:mqtt-client-core'
implementation 'org.meshtastic:mqtt-client-transport-tcp'
implementation 'org.meshtastic:mqtt-client-transport-ws'
}
}
}
}dependencies {
implementation(platform("org.meshtastic:mqtt-client-bom:0.5.0"))
implementation("org.meshtastic:mqtt-client-core")
implementation("org.meshtastic:mqtt-client-transport-tcp")
}import org.meshtastic.mqtt.*
import org.meshtastic.mqtt.transport.tcp.TcpTransportFactory
// Create a client with the factory DSL
val client = MqttClient("my-client") {
transportFactory = TcpTransportFactory() // from mqtt-client-transport-tcp
keepAliveSeconds = 30
autoReconnect = true
defaultQos = QoS.AT_LEAST_ONCE // all publishes default to QoS 1
}
// Connect, work, and auto-close
client.use(MqttEndpoint.parse("tcp://broker.example.com:1883")) { c ->
// Subscribe
c.subscribe("sensors/temperature")
// Publish (uses defaultQos from config)
c.publish("sensors/temperature", "22.5")
// Collect messages
c.messagesForTopic("sensors/temperature").collect { msg ->
println("Received: ${msg.payloadAsString()}")
}
}val config = MqttConfig(
clientId = "my-client",
keepAliveSeconds = 30,
autoReconnect = true,
transportFactory = TcpTransportFactory(),
)
val client = MqttClient(config)
client.connect(MqttEndpoint.Tcp(host = "broker.example.com", port = 1883))
client.subscribe("sensors/temperature", QoS.AT_LEAST_ONCE)
client.publish(
MqttMessage(
topic = "sensors/temperature",
payload = ByteString("22.5".encodeToByteArray()),
qos = QoS.AT_LEAST_ONCE,
),
)
client.messages.collect { msg ->
if (msg.topic == "sensors/temperature") {
println("Received: ${msg.payload.toByteArray().decodeToString()}")
}
}
client.close()By default, the client automatically negotiates the protocol version. It connects with MQTT 5.0 first and, if the broker rejects it with UNSUPPORTED_PROTOCOL_VERSION, seamlessly retries with MQTT 3.1.1 on a fresh connection — no configuration needed:
// Auto-negotiation is on by default — works with both 5.0 and 3.1.1 brokers
val client = MqttClient("my-client") {
transportFactory = TcpTransportFactory()
keepAliveSeconds = 30
}
client.use(MqttEndpoint.parse("tcp://any-broker:1883")) { c ->
// After connect, check which version was negotiated:
println("Connected with ${c.negotiatedProtocolVersion}")
c.subscribe("sensors/#")
c.messagesForTopic("sensors/#").collect { msg ->
println("Received: ${msg.payloadAsString()}")
}
}To force a specific version or disable negotiation:
// Force MQTT 3.1.1 (no negotiation)
val v311Client = MqttClient("my-client") {
protocolVersion = MqttProtocolVersion.V3_1_1
}
// Force MQTT 5.0 only (disable fallback)
val v5OnlyClient = MqttClient("my-client") {
negotiateVersion = false
}MQTT 3.1.1 mode automatically:
ReasonCode)noLocal, retainAsPublished, retainHandling)5.0-only config options (sessionExpiryInterval, authenticationMethod) are rejected at config-build time when V3_1_1 is explicitly selected. When using auto-negotiation, fallback is skipped if the config uses 5.0-only features — the original rejection is re-thrown so you know the broker doesn't support your configuration.
The library ships several ergonomic extensions to reduce boilerplate:
| API | What it replaces |
|---|---|
MqttClient("id") { ... } |
MqttClient(MqttConfig(clientId = "id", ...)) |
MqttEndpoint.parse("tcp://host:1883") |
MqttEndpoint.Tcp(host, port, tls) |
client.use(endpoint) { ... } |
Manual connect + try/finally { close() }
|
msg.payloadAsString() |
msg.payload.toByteArray().decodeToString() |
client.messagesForTopic("x") |
client.messages.filter { it.topic == "x" } |
client.messagesMatching("x/+/y") |
Manual wildcard matching on messages flow |
client.publish(topic, payload) |
Constructing MqttMessage manually |
defaultQos / defaultRetain
|
Repeating qos = QoS.AT_LEAST_ONCE on every publish |
will { topic = ...; payload("...") } |
will = WillConfig(topic = ..., payload = ByteString(...)) |
Parse broker URIs instead of constructing endpoints manually:
MqttEndpoint.parse("tcp://broker:1883") // Plain TCP
MqttEndpoint.parse("ssl://broker:8883") // TCP + TLS
MqttEndpoint.parse("mqtts://broker") // TLS, default port 8883
MqttEndpoint.parse("wss://broker/mqtt") // Secure WebSocket// Exact topic match
client.messagesForTopic("sensors/temperature").collect { ... }
// Wildcard filter (supports + and #)
client.messagesMatching("sensors/+/temperature").collect { ... }Use the builder DSL for complex configurations (annotated with @MqttDsl for scope safety, like Ktor's @KtorDsl):
val config = MqttConfig.build {
clientId = "sensor-hub-01"
keepAliveSeconds = 30
cleanStart = false
autoReconnect = true
defaultQos = QoS.AT_LEAST_ONCE
logger = MqttLogger.println()
logLevel = MqttLogLevel.DEBUG
will {
topic = "sensors/status"
payload("offline")
qos = QoS.AT_LEAST_ONCE
retain = true
}
}The library provides a zero-overhead logging interface. When no logger is configured (the default), message lambdas are never evaluated:
// Built-in println logger for quick debugging
val config = MqttConfig(
clientId = "debug-client",
logger = MqttLogger.println(),
logLevel = MqttLogLevel.DEBUG,
)
// Custom logger (e.g., forwarding to your app's logging framework)
val config = MqttConfig(
clientId = "production-client",
logger = object : MqttLogger {
override fun log(level: MqttLogLevel, tag: String, message: String, throwable: Throwable?) {
myAppLogger.log(level.name, "[$tag] $message", throwable)
}
},
logLevel = MqttLogLevel.INFO,
)Log levels from most to least verbose: TRACE → DEBUG → INFO → WARN → ERROR → NONE.
By default both transports validate the broker certificate against the platform CA store. To
reach a broker behind a private or self-signed CA, pass a TLS customisation lambda to the
transport factory you use — TcpTransportFactory, WebSocketTransportFactory, or both, since a
factory without the lambda keeps validating against the platform store alone. It runs against
ktor's TLSConfigBuilder:
import org.meshtastic.mqtt.transport.tcp.TcpTransportFactory
val client = MqttClient("my-client") {
transportFactory = TcpTransportFactory { trustManager = myPrivateCaTrustManager }
}
client.connect(MqttEndpoint.parse("mqtts://broker.internal:8883"))The hook is applied after the SNI server name is resolved and before platform trust is configured.
On Android that ordering means your trust manager is reached through the hostname-aware
checkServerTrusted(chain, authType, hostname) overload, which the platform requires whenever
network_security_config.xml holds any domain-specific configuration — rather than being discarded
in favour of the platform wrapper.
Be clear about what that does not buy you. Installing your own trust manager replaces the
platform's trust decision: your anchors are used instead of the platform's, and
network_security_config.xml anchors, certificate pinning, and Certificate Transparency policy are
then enforced only insofar as your manager enforces them itself. Those platform policies apply as
before only if you leave trustManager unset. The wrapping preserves the hostname-aware call
path, not the platform's policy.
RFC 6125 subject-name matching is separate again. Android's 3-arg overload uses the hostname for
policy lookup, not for subject-name matching; that check comes from ktor and only runs when the SNI
server name is set, so it is absent for IP-literal brokers such as mqtts://192.168.1.50:8883. On
JVM and native targets there is no platform trust wrapping at all, so ktor's SNI-gated subject-name
check is the only peer-identity verification beyond chain validation. If your trust manager accepts
any chain, nothing else will stop a mismatched certificate.
On Android the manager must be one X509TrustManagerExtensions can wrap: either obtained from a
TrustManagerFactory initialised with a KeyStore containing your CA, or declaring the three-arg
checkServerTrusted(chain, authType, host) that the platform looks up reflectively. A hand-written
X509TrustManager implementing only the two-arg overload cannot be wrapped, and the handshake fails
with an IllegalArgumentException explaining this.
This scopes the extra trust to the MQTT connection alone. It replaces the app-wide workaround of
adding <certificates src="https://raw.githubusercontent.com/meshtastic/MQTTastic-Client-KMP/main/user"/> to network_security_config.xml, which would affect every
HTTPS connection the app makes.
The hook composes with transport selection as usual, and both transports take the same lambda type, so one trust manager can serve both:
transportFactory = TcpTransportFactory { trustManager = myPrivateCaTrustManager } +
WebSocketTransportFactory { trustManager = myPrivateCaTrustManager }TLSConfigBuilder comes from io.ktor:ktor-network-tls, exposed transitively by both
mqtt-client-transport-tcp and mqtt-client-transport-ws — no extra dependency needed.
trustManager specifically is available on the JVM and Android actuals of TLSConfigBuilder; on
Apple and Linux the hook still runs, but TLSConfigBuilder exposes a different set of properties
there. The WebSocket hook is additionally ignored on Windows (the WinHttp engine has no
TLS-configuration surface) and in the browser (which cannot influence trust) — see
transport-ws/Module.md for the per-target table.
The library is designed as a drop-in MQTT client for KMP projects. Consumer ProGuard/R8 rules are bundled automatically.
class MqttViewModel : ViewModel() {
private val client = MqttClient("my-device") {
// broker URI may be tcp:// or ws://, so accept either transport
transportFactory = TcpTransportFactory() + WebSocketTransportFactory()
autoReconnect = true
keepAliveSeconds = 30
}
val connectionState = client.connectionState
fun connect(broker: String) {
viewModelScope.launch {
client.connect(MqttEndpoint.parse(broker))
client.subscribe("msh/2/e/#", QoS.AT_LEAST_ONCE)
}
}
fun observeMessages() = client.messagesMatching("msh/2/e/+/!/#")
override fun onCleared() {
viewModelScope.launch { client.close() }
}
}@Composable
fun MqttScreen(viewModel: MqttViewModel) {
val state by viewModel.connectionState.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
viewModel.observeMessages().collect { msg ->
// Process message
}
}
}The library uses Ktor 3.5.1 and kotlinx-coroutines 1.11.0. If your project uses the same versions, no conflicts will arise. Pin versions in your libs.versions.toml to avoid Gradle resolution surprises.
| Feature | Status | Spec Section |
|---|---|---|
| All 15 packet types | ✅ | §2.1 |
| Variable Byte Integer encoding | ✅ | §1.5.5 |
| UTF-8 string pairs | ✅ | §1.5.7 |
| Feature | Status | Spec Section |
|---|---|---|
| QoS 0 (at most once) | ✅ | §4.3.1 |
| QoS 1 (at least once) | ✅ | §4.3.2 |
| QoS 2 (exactly once) | ✅ | §4.3.3 |
| Duplicate detection (DUP flag) | ✅ | §3.3.1.1 |
| Feature | Status | Spec Section |
|---|---|---|
| Session management (cleanStart) | ✅ | §3.1.2.4 |
| Will messages & Will Delay | ✅ | §3.1.3.2 |
| Keep-alive & PINGREQ/PINGRESP | ✅ | §3.1.2.10 |
| Automatic reconnection | ✅ | — |
| Server redirect | ✅ | §4.13 |
| Feature | Status | Spec Section |
|---|---|---|
| Topic aliases | ✅ | §3.3.2.3.4 |
| Enhanced authentication (AUTH) | ✅ | §4.12 |
| Flow control (Receive Maximum) | ✅ | §3.3.4 |
| Request/Response pattern | ✅ | §4.10 |
| Shared subscriptions | ✅ | §4.8.2 |
| Subscription identifiers | ✅ | §3.8.3.1 |
| Topic filter validation | ✅ | §4.7 |
| Feature | Status | Spec Section |
|---|---|---|
| Configurable logging (6 levels) | ✅ | — |
| Connection state observation | ✅ | — |
| Limitation | Detail |
|---|---|
| Enhanced auth during CONNECT | Auth challenges are delivered only after the connection is established. SASL-style challenge/response during the CONNECT handshake (§4.12.1) is not yet supported. |
| Client-side session persistence | When cleanStart=false, the broker resumes session state, but the client does not persist in-flight QoS 1/2 messages across reconnects. Unacknowledged messages may be lost. |
See CONTRIBUTING.md for build setup, development workflow, and the full command reference.
| Resource | Link |
|---|---|
| API Reference | meshtastic.github.io/MQTTastic-Client-KMP |
| Configuration Guide | docs/configuration.md |
| Topics & QoS Guide | docs/topics-and-qos.md |
| MQTT 5.0 Specification | OASIS MQTT v5.0 |
| Changelog | CHANGELOG.md |
Contributions are welcome! Please read CONTRIBUTING.md for guidelines on:
For vulnerability reports, see the Security Policy. All participants are expected to follow the Code of Conduct.
This project is licensed under the GNU General Public License v3.0, consistent with all repositories in the Meshtastic organization.
A fully-featured MQTT 5.0 and 3.1.1 client library for Kotlin Multiplatform — connecting JVM, Android, iOS, macOS, Linux, Windows, and browsers through a single, idiomatic Kotlin API.
The bundled Compose Multiplatform sample app, live on tls://mqtt.meshtastic.org:8883.
suspend functions and Flow-based message deliveryByteString payloads, validated inputs, data class modelsMqttLogger interface with level filtering| MQTTastic | Typical alternatives | |
|---|---|---|
| Pure KMP | Single codebase, single API across all platforms | Wrappers around platform SDKs (Paho, Mosquitto) |
| MQTT 5.0 first | Built from the ground up for 5.0 — not retrofitted from 3.1.1 | Bolt-on 5.0 support with incomplete property coverage |
| Coroutines-native |
suspend functions and Flow everywhere — no callbacks, no blocking |
Callback-heavy APIs requiring manual coroutine bridging |
| Zero platform deps | Only Ktor + kotlinx-coroutines + kotlinx-io | Bundles native C libraries or platform-specific SDKs |
| Immutable & validated |
ByteString payloads, validated topic filters, range-checked properties |
Mutable byte arrays, silent truncation, unchecked inputs |
| Platform | Target | Transport | Status |
|---|---|---|---|
| JVM | jvm |
TCP/TLS, WebSocket | ✅ |
| Android | android |
TCP/TLS, WebSocket | ✅ |
| iOS |
iosArm64, iosSimulatorArm64
|
TCP/TLS, WebSocket | ✅ |
| macOS | macosArm64 |
TCP/TLS, WebSocket | ✅ |
| Linux |
linuxX64, linuxArm64
|
TCP/TLS, WebSocket | ✅ |
| Windows | mingwX64 |
TCP/TLS, WebSocket | ✅ |
| Browser | wasmJs |
WebSocket | ✅ |
All protocol logic — packet encoding/decoding, the client state machine, QoS flows, and property handling — lives in the mqtt-client-core module as pure commonMain Kotlin, with zero transport dependencies. Each transport ships as its own artifact, so a consumer pulls in only what it uses. Every bug fix, feature, and optimization in core applies to all 9 targets simultaneously.
┌─────────────────────────────────────────────┐
│ mqtt-client-core │ ← public API: suspend + Flow
│ MqttClient / MqttConnection / QoS machines │ ← protocol logic, keepalive
│ MqttPacket / Encoder / Decoder │ ← MQTT 5.0 wire format
│ MqttTransport / MqttTransportFactory (SPI) │ ← the transport seam
└───────────────────────┬─────────────────────┘
▲ │ api(core) ▲
│ ▼ │
┌───────────┴───────────┐ ┌───────────────────┴───────────┐
│ mqtt-client- │ │ mqtt-client-transport-ws │
│ transport-tcp │ │ WebSocketTransport(Factory) │
│ TcpTransport(Factory) │ │ ktor-client-websockets │
│ ktor-network + TLS │ │ all targets incl. browser │
│ (no browser) │ │ │
└───────────────────────┘ └────────────────────────────────┘
MqttTransport / MqttTransportFactory are the public service-provider interface — the sole platform abstraction boundary. Core has no compile-time dependency on any transport module; you supply a factory (TcpTransportFactory, WebSocketTransportFactory, or both combined with +) via MqttConfig.Builder.transportFactory. Coroutines drive everything: suspend functions for operations, SharedFlow<MqttMessage> for incoming messages, and StateFlow<ConnectionState> for lifecycle observation.
Artifacts are published to Maven Central under the org.meshtastic group. Depend on
mqtt-client-core plus the transport(s) you need. The mqtt-client-bom pins every module to one
version so you don't repeat it:
// settings.gradle.kts
repositories {
mavenCentral()
}
// build.gradle.kts
kotlin {
sourceSets {
commonMain.dependencies {
implementation(platform("org.meshtastic:mqtt-client-bom:0.5.0"))
implementation("org.meshtastic:mqtt-client-core")
// Pick the transport(s) you actually use:
implementation("org.meshtastic:mqtt-client-transport-tcp") // TCP/TLS — every target except browser
implementation("org.meshtastic:mqtt-client-transport-ws") // WebSocket — every target incl. browser
}
}
}Then supply the matching factory when building the client (combine with + if you use both):
val client = MqttClient("my-client") {
transportFactory = TcpTransportFactory() + WebSocketTransportFactory()
}Browser (wasmJs) can only use
mqtt-client-transport-ws— raw TCP is unavailable there.
// build.gradle
kotlin {
sourceSets {
commonMain {
dependencies {
implementation platform('org.meshtastic:mqtt-client-bom:0.5.0')
implementation 'org.meshtastic:mqtt-client-core'
implementation 'org.meshtastic:mqtt-client-transport-tcp'
implementation 'org.meshtastic:mqtt-client-transport-ws'
}
}
}
}dependencies {
implementation(platform("org.meshtastic:mqtt-client-bom:0.5.0"))
implementation("org.meshtastic:mqtt-client-core")
implementation("org.meshtastic:mqtt-client-transport-tcp")
}import org.meshtastic.mqtt.*
import org.meshtastic.mqtt.transport.tcp.TcpTransportFactory
// Create a client with the factory DSL
val client = MqttClient("my-client") {
transportFactory = TcpTransportFactory() // from mqtt-client-transport-tcp
keepAliveSeconds = 30
autoReconnect = true
defaultQos = QoS.AT_LEAST_ONCE // all publishes default to QoS 1
}
// Connect, work, and auto-close
client.use(MqttEndpoint.parse("tcp://broker.example.com:1883")) { c ->
// Subscribe
c.subscribe("sensors/temperature")
// Publish (uses defaultQos from config)
c.publish("sensors/temperature", "22.5")
// Collect messages
c.messagesForTopic("sensors/temperature").collect { msg ->
println("Received: ${msg.payloadAsString()}")
}
}val config = MqttConfig(
clientId = "my-client",
keepAliveSeconds = 30,
autoReconnect = true,
transportFactory = TcpTransportFactory(),
)
val client = MqttClient(config)
client.connect(MqttEndpoint.Tcp(host = "broker.example.com", port = 1883))
client.subscribe("sensors/temperature", QoS.AT_LEAST_ONCE)
client.publish(
MqttMessage(
topic = "sensors/temperature",
payload = ByteString("22.5".encodeToByteArray()),
qos = QoS.AT_LEAST_ONCE,
),
)
client.messages.collect { msg ->
if (msg.topic == "sensors/temperature") {
println("Received: ${msg.payload.toByteArray().decodeToString()}")
}
}
client.close()By default, the client automatically negotiates the protocol version. It connects with MQTT 5.0 first and, if the broker rejects it with UNSUPPORTED_PROTOCOL_VERSION, seamlessly retries with MQTT 3.1.1 on a fresh connection — no configuration needed:
// Auto-negotiation is on by default — works with both 5.0 and 3.1.1 brokers
val client = MqttClient("my-client") {
transportFactory = TcpTransportFactory()
keepAliveSeconds = 30
}
client.use(MqttEndpoint.parse("tcp://any-broker:1883")) { c ->
// After connect, check which version was negotiated:
println("Connected with ${c.negotiatedProtocolVersion}")
c.subscribe("sensors/#")
c.messagesForTopic("sensors/#").collect { msg ->
println("Received: ${msg.payloadAsString()}")
}
}To force a specific version or disable negotiation:
// Force MQTT 3.1.1 (no negotiation)
val v311Client = MqttClient("my-client") {
protocolVersion = MqttProtocolVersion.V3_1_1
}
// Force MQTT 5.0 only (disable fallback)
val v5OnlyClient = MqttClient("my-client") {
negotiateVersion = false
}MQTT 3.1.1 mode automatically:
ReasonCode)noLocal, retainAsPublished, retainHandling)5.0-only config options (sessionExpiryInterval, authenticationMethod) are rejected at config-build time when V3_1_1 is explicitly selected. When using auto-negotiation, fallback is skipped if the config uses 5.0-only features — the original rejection is re-thrown so you know the broker doesn't support your configuration.
The library ships several ergonomic extensions to reduce boilerplate:
| API | What it replaces |
|---|---|
MqttClient("id") { ... } |
MqttClient(MqttConfig(clientId = "id", ...)) |
MqttEndpoint.parse("tcp://host:1883") |
MqttEndpoint.Tcp(host, port, tls) |
client.use(endpoint) { ... } |
Manual connect + try/finally { close() }
|
msg.payloadAsString() |
msg.payload.toByteArray().decodeToString() |
client.messagesForTopic("x") |
client.messages.filter { it.topic == "x" } |
client.messagesMatching("x/+/y") |
Manual wildcard matching on messages flow |
client.publish(topic, payload) |
Constructing MqttMessage manually |
defaultQos / defaultRetain
|
Repeating qos = QoS.AT_LEAST_ONCE on every publish |
will { topic = ...; payload("...") } |
will = WillConfig(topic = ..., payload = ByteString(...)) |
Parse broker URIs instead of constructing endpoints manually:
MqttEndpoint.parse("tcp://broker:1883") // Plain TCP
MqttEndpoint.parse("ssl://broker:8883") // TCP + TLS
MqttEndpoint.parse("mqtts://broker") // TLS, default port 8883
MqttEndpoint.parse("wss://broker/mqtt") // Secure WebSocket// Exact topic match
client.messagesForTopic("sensors/temperature").collect { ... }
// Wildcard filter (supports + and #)
client.messagesMatching("sensors/+/temperature").collect { ... }Use the builder DSL for complex configurations (annotated with @MqttDsl for scope safety, like Ktor's @KtorDsl):
val config = MqttConfig.build {
clientId = "sensor-hub-01"
keepAliveSeconds = 30
cleanStart = false
autoReconnect = true
defaultQos = QoS.AT_LEAST_ONCE
logger = MqttLogger.println()
logLevel = MqttLogLevel.DEBUG
will {
topic = "sensors/status"
payload("offline")
qos = QoS.AT_LEAST_ONCE
retain = true
}
}The library provides a zero-overhead logging interface. When no logger is configured (the default), message lambdas are never evaluated:
// Built-in println logger for quick debugging
val config = MqttConfig(
clientId = "debug-client",
logger = MqttLogger.println(),
logLevel = MqttLogLevel.DEBUG,
)
// Custom logger (e.g., forwarding to your app's logging framework)
val config = MqttConfig(
clientId = "production-client",
logger = object : MqttLogger {
override fun log(level: MqttLogLevel, tag: String, message: String, throwable: Throwable?) {
myAppLogger.log(level.name, "[$tag] $message", throwable)
}
},
logLevel = MqttLogLevel.INFO,
)Log levels from most to least verbose: TRACE → DEBUG → INFO → WARN → ERROR → NONE.
By default both transports validate the broker certificate against the platform CA store. To
reach a broker behind a private or self-signed CA, pass a TLS customisation lambda to the
transport factory you use — TcpTransportFactory, WebSocketTransportFactory, or both, since a
factory without the lambda keeps validating against the platform store alone. It runs against
ktor's TLSConfigBuilder:
import org.meshtastic.mqtt.transport.tcp.TcpTransportFactory
val client = MqttClient("my-client") {
transportFactory = TcpTransportFactory { trustManager = myPrivateCaTrustManager }
}
client.connect(MqttEndpoint.parse("mqtts://broker.internal:8883"))The hook is applied after the SNI server name is resolved and before platform trust is configured.
On Android that ordering means your trust manager is reached through the hostname-aware
checkServerTrusted(chain, authType, hostname) overload, which the platform requires whenever
network_security_config.xml holds any domain-specific configuration — rather than being discarded
in favour of the platform wrapper.
Be clear about what that does not buy you. Installing your own trust manager replaces the
platform's trust decision: your anchors are used instead of the platform's, and
network_security_config.xml anchors, certificate pinning, and Certificate Transparency policy are
then enforced only insofar as your manager enforces them itself. Those platform policies apply as
before only if you leave trustManager unset. The wrapping preserves the hostname-aware call
path, not the platform's policy.
RFC 6125 subject-name matching is separate again. Android's 3-arg overload uses the hostname for
policy lookup, not for subject-name matching; that check comes from ktor and only runs when the SNI
server name is set, so it is absent for IP-literal brokers such as mqtts://192.168.1.50:8883. On
JVM and native targets there is no platform trust wrapping at all, so ktor's SNI-gated subject-name
check is the only peer-identity verification beyond chain validation. If your trust manager accepts
any chain, nothing else will stop a mismatched certificate.
On Android the manager must be one X509TrustManagerExtensions can wrap: either obtained from a
TrustManagerFactory initialised with a KeyStore containing your CA, or declaring the three-arg
checkServerTrusted(chain, authType, host) that the platform looks up reflectively. A hand-written
X509TrustManager implementing only the two-arg overload cannot be wrapped, and the handshake fails
with an IllegalArgumentException explaining this.
This scopes the extra trust to the MQTT connection alone. It replaces the app-wide workaround of
adding <certificates src="https://raw.githubusercontent.com/meshtastic/MQTTastic-Client-KMP/main/user"/> to network_security_config.xml, which would affect every
HTTPS connection the app makes.
The hook composes with transport selection as usual, and both transports take the same lambda type, so one trust manager can serve both:
transportFactory = TcpTransportFactory { trustManager = myPrivateCaTrustManager } +
WebSocketTransportFactory { trustManager = myPrivateCaTrustManager }TLSConfigBuilder comes from io.ktor:ktor-network-tls, exposed transitively by both
mqtt-client-transport-tcp and mqtt-client-transport-ws — no extra dependency needed.
trustManager specifically is available on the JVM and Android actuals of TLSConfigBuilder; on
Apple and Linux the hook still runs, but TLSConfigBuilder exposes a different set of properties
there. The WebSocket hook is additionally ignored on Windows (the WinHttp engine has no
TLS-configuration surface) and in the browser (which cannot influence trust) — see
transport-ws/Module.md for the per-target table.
The library is designed as a drop-in MQTT client for KMP projects. Consumer ProGuard/R8 rules are bundled automatically.
class MqttViewModel : ViewModel() {
private val client = MqttClient("my-device") {
// broker URI may be tcp:// or ws://, so accept either transport
transportFactory = TcpTransportFactory() + WebSocketTransportFactory()
autoReconnect = true
keepAliveSeconds = 30
}
val connectionState = client.connectionState
fun connect(broker: String) {
viewModelScope.launch {
client.connect(MqttEndpoint.parse(broker))
client.subscribe("msh/2/e/#", QoS.AT_LEAST_ONCE)
}
}
fun observeMessages() = client.messagesMatching("msh/2/e/+/!/#")
override fun onCleared() {
viewModelScope.launch { client.close() }
}
}@Composable
fun MqttScreen(viewModel: MqttViewModel) {
val state by viewModel.connectionState.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
viewModel.observeMessages().collect { msg ->
// Process message
}
}
}The library uses Ktor 3.5.1 and kotlinx-coroutines 1.11.0. If your project uses the same versions, no conflicts will arise. Pin versions in your libs.versions.toml to avoid Gradle resolution surprises.
| Feature | Status | Spec Section |
|---|---|---|
| All 15 packet types | ✅ | §2.1 |
| Variable Byte Integer encoding | ✅ | §1.5.5 |
| UTF-8 string pairs | ✅ | §1.5.7 |
| Feature | Status | Spec Section |
|---|---|---|
| QoS 0 (at most once) | ✅ | §4.3.1 |
| QoS 1 (at least once) | ✅ | §4.3.2 |
| QoS 2 (exactly once) | ✅ | §4.3.3 |
| Duplicate detection (DUP flag) | ✅ | §3.3.1.1 |
| Feature | Status | Spec Section |
|---|---|---|
| Session management (cleanStart) | ✅ | §3.1.2.4 |
| Will messages & Will Delay | ✅ | §3.1.3.2 |
| Keep-alive & PINGREQ/PINGRESP | ✅ | §3.1.2.10 |
| Automatic reconnection | ✅ | — |
| Server redirect | ✅ | §4.13 |
| Feature | Status | Spec Section |
|---|---|---|
| Topic aliases | ✅ | §3.3.2.3.4 |
| Enhanced authentication (AUTH) | ✅ | §4.12 |
| Flow control (Receive Maximum) | ✅ | §3.3.4 |
| Request/Response pattern | ✅ | §4.10 |
| Shared subscriptions | ✅ | §4.8.2 |
| Subscription identifiers | ✅ | §3.8.3.1 |
| Topic filter validation | ✅ | §4.7 |
| Feature | Status | Spec Section |
|---|---|---|
| Configurable logging (6 levels) | ✅ | — |
| Connection state observation | ✅ | — |
| Limitation | Detail |
|---|---|
| Enhanced auth during CONNECT | Auth challenges are delivered only after the connection is established. SASL-style challenge/response during the CONNECT handshake (§4.12.1) is not yet supported. |
| Client-side session persistence | When cleanStart=false, the broker resumes session state, but the client does not persist in-flight QoS 1/2 messages across reconnects. Unacknowledged messages may be lost. |
See CONTRIBUTING.md for build setup, development workflow, and the full command reference.
| Resource | Link |
|---|---|
| API Reference | meshtastic.github.io/MQTTastic-Client-KMP |
| Configuration Guide | docs/configuration.md |
| Topics & QoS Guide | docs/topics-and-qos.md |
| MQTT 5.0 Specification | OASIS MQTT v5.0 |
| Changelog | CHANGELOG.md |
Contributions are welcome! Please read CONTRIBUTING.md for guidelines on:
For vulnerability reports, see the Security Policy. All participants are expected to follow the Code of Conduct.
This project is licensed under the GNU General Public License v3.0, consistent with all repositories in the Meshtastic organization.