<context_and_memory>
- Project:
org.meshtastic:mqtt-client-coreplus per-transport modules (-transport-tcp,-transport-ws) and a-bom— production-grade MQTT 5.0 and 3.1.1 client for Kotlin Multiplatform (JVM, Android, iOS, macOS, Linux, Windows, wasmJs). - Stack: Kotlin 2.4.10, Gradle 9.6.1, Ktor 3.5.1, kotlinx-coroutines 1.11.0, kotlinx-io-bytestring 0.9.1. Zero external deps beyond these.
- Reference Spec: OASIS MQTT 5.0 and OASIS MQTT 3.1.1 — consult for byte-level packet layouts, property definitions, and reason codes. </context_and_memory>
The library is split into a transport-free :core plus per-transport modules (ADR-0006):
:core (mqtt-client-core) — commonMain, all 9 targets
MqttClient — public API surface
└─ MqttConnection — lifecycle, keepalive, read loop, QoS state machines
└─ MqttTransport / MqttTransportFactory — PUBLIC SPI interface
implemented by separately-published modules that each api(project(":core")):
:transport-tcp (mqtt-client-transport-tcp) — TcpTransport, ktor-network + TLS (all targets EXCEPT wasmJs)
:transport-ws (mqtt-client-transport-ws) — WebSocketTransport, ktor-client-websockets (all targets incl. browser)
MqttTransport and MqttTransportFactory are public SPI in :core — there is no
expect/actual transport factory. The consumer supplies a factory (TcpTransportFactory,
WebSocketTransportFactory, or both combined with +) via MqttConfig.Builder.transportFactory.
:core has zero compile-time dependency on any transport module (guarded by a configuration-time
check; see core/build.gradle.kts verifyModuleBoundary). Everything in :core is pure common Kotlin.
:core (mqtt-client-core) commonMain ← ALL protocol logic: packets, encoder, decoder, client,
connection, properties, + the MqttTransport/Factory SPI
commonTest ← codec/client/QoS tests via FakeTransport
jvmTest ← Konsist architecture suite + env-gated real-broker
integration tests (testImplementation project(":transport-tcp"))
:transport-tcp commonMain (TcpTransport) + jvm/android/native PlatformTls expect/actual.
Targets: jvm, android, apple (ios/macos), linux, mingw — NO wasmJs.
:transport-ws commonMain (WebSocketTransport) + cioMain (CIO engine + TLS trust hook)
+ per-platform Ktor engine deps. Targets: all, incl. wasmJs.
:bom (mqtt-client-bom) java-platform BOM pinning every artifact to one version.
build-logic/convention mqtt.kmp.library + mqtt.publishing convention plugins (shared KMP target
set + per-module publishing coordinates derived from the module name).
Each library module applies applyDefaultHierarchyTemplate() (via mqtt.kmp.library), so
nativeMain/appleMain/linuxMain/mingwMain are auto-created. macosArm64 only (macosX64
deprecated in Kotlin 2.3.20). The one custom intermediate source set is :transport-ws's cioMain
(jvm + android + apple + linux), which holds the single CIO HttpClient builder and the TLS trust
plumbing (applyWsTls and the configurePlatformTrust expect/actuals). The old nonWebMain set is
gone: :transport-tcp simply omits the wasmJs target, so its commonMain is effectively the old
"non-web" set.
ByteArray ←→ MqttDecoder/MqttEncoder ←→ MqttPacket (sealed interface hierarchy)
MqttPacket is a sealed interface with data class implementations for each of the 15 MQTT 5.0 packet types. Encode and decode are separate top-level/extension functions, not methods on the packet classes. Properties are modeled as a dedicated MqttProperties class shared across packet types.
All protocol features are fully implemented: 15 MQTT 5.0 packet types, QoS 0/1/2 state machines, enhanced auth, topic aliases, flow control, auto-reconnect, will messages, shared subscriptions, and request/response. MQTT 3.1.1 is also fully supported via a MqttProtocolVersion enum threaded through the codec and connection layers. See README.md for detailed coverage tables.
./gradlew build # full build lifecycle (compile + test + check) for all targets
./gradlew allTests # run ALL tests (KMP lifecycle task)
./gradlew jvmTest # JVM tests only
./gradlew wasmJsTest # wasmJs tests only
# Single test class:
./gradlew jvmTest --tests "org.meshtastic.mqtt.MqttEncoderDecoderTest"
# Single test method:
./gradlew jvmTest --tests "org.meshtastic.mqtt.MqttEncoderDecoderTest.encodeConnectPacket"
# Formatting & linting:
./gradlew spotlessCheck # check formatting
./gradlew spotlessApply # auto-fix formatting
./gradlew detektAll # static analysis (all Kotlin source sets)
# Binary compatibility & coverage:
./gradlew apiCheck # verify public API hasn't changed
./gradlew apiDump # regenerate API baseline after intentional changes
./gradlew koverVerify # check code coverage (≥80% enforced)
./gradlew koverHtmlReport # generate HTML coverage report
# Documentation:
./gradlew dokkaGeneratePublicationHtml # generate API docs to library/build/dokka/html/
# Full baseline verification:
./gradlew spotlessCheck detektAll allTests apiCheck koverVerify
# Publish locally:
./gradlew publishToMavenLocal
detektAllvsdetekt: Always usedetektAll. The detekt plugin's baredetekttask only knows the JVM-stylesrc/main/kotlinlayout, so in every module here it isNO-SOURCEand analyses nothing.detektAll(registered by themqtt.kmp.libraryconvention plugin) aggregates the per-source-set tasks —detektMetadataCommonMain,detektJvmMain,detektJvmTest,detektAndroidMain,detektWasmJsMain,detektLinuxX64Main, … — and is wired intocheck, sobuildcovers it too.
allTestsvstest: Always useallTests— it is the KMP lifecycle task that correctly covers all source sets. The baretesttask is ambiguous in KMP projects and Gradle may silently skip targets.
Build system: Kotlin DSL (build.gradle.kts) with version catalog (gradle/libs.versions.toml).
Send PINGREQ every keepAliveSeconds * 0.75 if no other packet was sent. If no PINGRESP within keepAliveSeconds, treat connection as dead and trigger reconnect (if enabled) or disconnect.
Ktor's byte channels are single-writer: two coroutines touching one channel corrupt its kotlinx.io
segment list (Segment.compact "Check failed.", or an NPE in the TLS writeRecord path — see
KTOR-7729, open upstream). Closing counts as
writing — socket.close() cancels that same channel and, under TLS, hands it to ktor's
cio-tls-closer coroutine to flush close_notify.
So sendMutex guards teardown as well as sends. MqttConnection.shutdownTransport is the only
path that closes the transport: it takes sendMutex, cancels and joins the read loop and keepalive
while holding it, then writes any DISCONNECT and closes — all under one lock acquisition, in a
NonCancellable block because the loops it cancels are usually its own caller. Both transports
guard close() the same way for direct SPI users. Every wait is bounded (2s) so a writer wedged on
a dead peer cannot stall a reconnect.
Anything new that writes to the transport, or closes it, has to join this discipline.
- TCP (
TcpTransport.receive()): Parse fixed header byte → decode variable-length remaining length → read exactly that many bytes. Handle partial reads correctly — this is the trickiest part. - WebSocket (
WebSocketTransport.receive()): One binary WebSocket frame = one MQTT packet. The WS layer handles framing, soreceive()just reads one frame.
- UTF-8 strings: 2-byte big-endian length prefix + UTF-8 bytes
- Variable Byte Integer (VBI): 7 data bits + 1 continuation bit (MSB) per byte, max 4 bytes (max value 268,435,455)
- Fixed header:
[packet type (4 bits)][flags (4 bits)]+ remaining length (VBI) - Properties section: property length (VBI) + sequence of
(property ID (VBI) + typed value) - Binary Data: 2-byte big-endian length prefix + raw bytes
Track each packet ID through: PUBLISH → PUBREC → PUBREL → PUBCOMP. Persist in-flight state for session resumption when cleanStart = false. Handle duplicate detection via the DUP flag.
Monotonically increasing 16-bit counter wrapping at 65535 using Mutex-guarded state. Track in-flight packets in a map keyed by packet ID for PUBACK/PUBREC/PUBREL/PUBCOMP correlation.
Honor the server's Receive Maximum property — do not exceed the allowed number of concurrent in-flight QoS 1/2 publishes. Use a semaphore or similar mechanism to block publish() when the limit is reached.
- All tests in
commonTestusingkotlin.test+kotlinx-coroutines-test - Encode/decode round-trips for every packet type with known byte sequences from the MQTT 5.0 spec
- Client state machine tests using a
FakeTransport(in-memory queue implementingMqttTransport) - QoS 2 flow tests covering the full PUBREC→PUBREL→PUBCOMP state machine, including retransmission and session resumption
- Property tests — encode/decode for every MQTT 5.0 property type
- Edge cases — malformed packets, partial reads, variable-length integer boundaries (0, 127, 128, 16383, 16384, 2097151, 2097152, 268435455)
- Integration tests (optional/manual) — connect to a real MQTT 5.0 broker
- Group:
org.meshtastic - Artifacts (one coordinate per module; the
mqtt.publishingconvention plugin derives the artifactId asmqtt-client-<module-name>):mqtt-client-coremqtt-client-transport-tcpmqtt-client-transport-wsmqtt-client-bom(ajava-platformBOM pinning the above to one version)
- Supported project targets: JVM, Android, iOS (iosArm64, iosSimulatorArm64), macOS (macosArm64), Linux (linuxX64, linuxArm64), Windows (mingwX64), wasmJs (
:transport-tcpomits wasmJs). - The vanniktech
maven-publishplugin auto-creates per-target publications (e.g.,mqtt-client-core-jvm,mqtt-client-core-iosarm64) and a rootkotlinMultiplatformpublication per module. - Android publishing requires the
android {}block (Android Gradle KMP Library Plugin,com.android.kotlin.multiplatform.library) in each library module'sbuild.gradle.kts. Configurenamespace,compileSdk, andminSdkinside it. Without this, Android artifacts will not be published. (The olderandroidLibrary {}block name is deprecated since AGP 9.1.0-alpha09 — useandroid {}on AGP 8.12+.) - For Apple platforms, Maven publishes
.klibartifacts. If XCFramework distribution is needed separately, that is a distinct build step (assembleXCFramework), not part of Maven publishing.
<git_and_prs>
- Commit Format: Conventional Commits —
<type>(<scope>): <subject>.- Types:
feat,fix,docs,style,refactor,test,chore - Scopes:
packet,transport,client,codec,qos,props,build,ci,deps - Subject: imperative mood, no period, under 50 chars.
- Types:
- Commit Hygiene: Squash fixup/polish commits before PR. Each commit = one logical unit of work.
- PR Titles: Conventional commit format, under 72 characters.
- PR Descriptions: State what changed and why. Bullet list of changes. Reference issues with
Fixes #N. </git_and_prs>
<documentation_sync>
AGENTS.md is the single source of truth for agent instructions. Agent-specific files redirect here:
.github/copilot-instructions.md— Copilot redirect toAGENTS.md.CLAUDE.md— Claude Code entry point; importsAGENTS.mdand adds Claude-specific instructions..github/instructions/— Copilot context-specific rules usingapplyTopatterns.
Do NOT duplicate content into agent-specific files. When you modify architecture, protocol scope, build tasks, or conventions, update AGENTS.md.
</documentation_sync>