Skip to content

MqttClient.kt

src/jvmMain/kotlin/afield/io/mqtt/MqttClient.kt · 446 lines

@file:afield.runtime.completion.CompletionCategory("Networking")

package afield.io.mqtt

import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.doubleOrNull
import java.io.BufferedInputStream
import java.io.IOException
import java.io.OutputStream
import java.net.InetSocketAddress
import java.net.Socket
import java.net.URI
import java.util.UUID
import java.util.concurrent.CompletableFuture
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong

An MQTT 3.1.1 client over plain TCP, MqttClient, with the message, subscription and connection event types it hands back.

The client reconnects with backoff, re-subscribes after a reconnect and re-sends unacknowledged QoS 1 publishes. Box code normally reaches it through mqtt, which routes callbacks onto the main Runloop.

class MqttMessage

One received message. text, number and json read the payload.

class MqttMessage(
    val topic: String,
    val payload: ByteArray,
    val qos: QoS = QoS.AT_MOST_ONCE,
    /** True when this is the broker replaying a retained message to a new subscription. */
    val retained: Boolean = false,
    /** `System.nanoTime()` when it arrived. */
    val receivedNanos: Long = System.nanoTime(),
) {

val text: String get() = payload.decodeToString()

The payload as UTF-8.

val number: Double? get()

The payload as a number: plain text ("21.5"), or a JSON number. Null when it is neither — for a JSON object, use json or number with a key.

    val number: Double? get() = text.trim().toDoubleOrNull() ?: (jsonOrNull as? JsonPrimitive)?.doubleOrNull

val json: JsonElement get() = Json.parseToJsonElement(text)

The payload parsed as JSON; throws on text that is not JSON.

val jsonOrNull: JsonElement? get() = runCatching { json }.getOrNull()

json, or null when the payload is not JSON.

fun number(path: String): Double? = (at(path) as? JsonPrimitive)?.doubleOrNull

A number inside a JSON payload, by a dotted path: number("temp") for {"temp": 21.5}, number("env.temp"), number("values.2") for an array index.

fun string(path: String): String? = (at(path) as? JsonPrimitive)?.content

A string inside a JSON payload, by dotted path; see number.

fun at(path: String): JsonElement?

The JSON element at a dotted path, or null.

    fun at(path: String): JsonElement? {
        var e: JsonElement = jsonOrNull ?: return null
        for (key in path.split('.')) {
            e = when (e) {
                is JsonObject -> e[key] ?: return null
                is JsonArray -> e.getOrNull(key.toIntOrNull() ?: return null) ?: return null
                else -> return null
            }
        }
        return e
    }

override fun toString()

    override fun toString() = "MqttMessage($topic = ${if (payload.size <= 64) text else "${payload.size} bytes"})"
}

class MqttSubscription internal constructor

A subscription made by MqttClient.subscribe; close it to stop the callback.

class MqttSubscription internal constructor(
    val filter: String,
    val qos: QoS,
    internal val handler: (MqttMessage) -> Unit,
    private val owner: MqttClient,
) : AutoCloseable {

var isClosed

    @Volatile
    var isClosed = false
        internal set

val count = AtomicLong()

Messages delivered to this subscription so far.

override fun close() = owner.unsubscribe(this)

data class MqttConnectionEvent(val connected: Boolean, val reason: String)

A connection change, passed to MqttClient.onConnection.

class MqttClient

An MQTT 3.1.1 client over plain TCP.

  • QoS 0 and 1. A QoS 1 publish is held until its PUBACK arrives and re-sent (with DUP) after a reconnect; the returned future completes on PUBACK. A QoS 2 publish is sent and completed with PUBREL/PUBCOMP, but resend of an interrupted QoS 2 flow is not implemented.
  • Reconnect. With autoReconnect, a dropped connection is retried with backoff (0.25 s doubling to 8 s), and every live subscription is made again on the new connection.
  • Keepalive. A PINGREQ goes out when nothing has been sent for keepAliveSeconds; no PINGRESP within the same interval drops the connection (and reconnects).
  • Last will (will) is published by the broker if this client vanishes without close.

Callbacks run through dispatch. In Field, afield.io.mqtt.mqtt passes one that queues onto the main Runloop, so a callback may touch boxes, sliders and graphics like any other box code; the default here calls on the client's reader thread, which is what tests and jvm-main want.

url is tcp://host[:port] (also mqtt://); the port defaults to 1883.

class MqttClient(
    val url: String,
    val clientId: String = "field4-" + UUID.randomUUID().toString().take(8),
    val keepAliveSeconds: Int = 30,
    val cleanSession: Boolean = true,
    val will: MqttPacket.Will? = null,
    val username: String? = null,
    val password: String? = null,
    val autoReconnect: Boolean = true,
    val connectTimeoutMs: Int = 3000,
    private val dispatch: (Runnable) -> Unit = { it.run() },
) : AutoCloseable {

val host: String

val port: Int

init

    init {
        val u = URI(if ("://" in url) url else "tcp://$url")
        require(u.scheme in setOf("tcp", "mqtt")) { "only tcp:// / mqtt:// urls are supported, not '$url'" }
        host = u.host ?: throw IllegalArgumentException("no host in '$url'")
        port = if (u.port > 0) u.port else Mqtt.PORT
    }

var isClosed

    @Volatile
    var isClosed = false
        private set

var isConnected

    @Volatile
    var isConnected = false
        private set

var lastError: String?

Why the last connection attempt failed or dropped, for a status line.

    @Volatile
    var lastError: String? = null
        private set

val connections = AtomicInteger()

Successful connections so far (1 + reconnects).

val received = AtomicLong()

Messages received, publishes sent.

val sent = AtomicLong()

val latest = ConcurrentHashMap<String, MqttMessage>()

The most recent message on every topic received, by topic.

val onConnection = CopyOnWriteArrayList<(MqttConnectionEvent) -> Unit>()

Called (through dispatch) when the connection comes up or goes down.

val onReceive = CopyOnWriteArrayList<(MqttMessage) -> Unit>()

Called on the reader thread for every message, before any subscription callback. Keep it short.

Local handlers. Separate from brokerSubscriptions, the cover of their filters.

    private val subscriptions = CopyOnWriteArrayList<MqttSubscription>()

Filters subscribed at the broker on this connection, with their QoS. Guarded by coverLock.

    private val brokerSubs = LinkedHashMap<String, QoS>()
    private val coverLock = Any()

Retained messages the client has seen, by topic, replayed to handlers the cover hides from the broker.

    private val retainedCache = ConcurrentHashMap<String, MqttMessage>()
    private val ids = AtomicInteger(0)
    private val pendingAcks = ConcurrentHashMap<Int, CompletableFuture<Unit>>()

Unacknowledged QoS 1 publishes, re-sent with DUP after a reconnect.

    private val unacked = ConcurrentHashMap<Int, MqttPacket.Publish>()
    private val lock = Any()
    @Volatile private var socket: Socket? = null
    @Volatile private var out: OutputStream? = null
    @Volatile private var lastSendNanos = 0L
    @Volatile private var pingSentNanos = 0L
    private var connectedFuture = CompletableFuture<Unit>()

    private val thread = Thread(::run, "mqtt-client-$clientId").apply { isDaemon = true; start() }

    private fun nextId(): Int {
        while (true) {
            val id = ids.updateAndGet { it % 65535 + 1 }
            if (!pendingAcks.containsKey(id)) return id
        }
    }

fun awaitConnected(timeoutMs: Long = 5000): Boolean

Blocks until connected; false after timeoutMs.

    fun awaitConnected(timeoutMs: Long = 5000): Boolean = try {
        synchronized(lock) { connectedFuture }.get(timeoutMs, TimeUnit.MILLISECONDS); true
    } catch (_: Exception) { isConnected }

    private fun send(p: MqttPacket): Boolean {
        val o = out ?: return false
        return try {
            synchronized(o) { o.write(Mqtt.encode(p)); o.flush() }
            lastSendNanos = System.nanoTime()
            true
        } catch (e: IOException) {
            drop("send failed: ${e.message}"); false
        }
    }

fun publish(topic: String, payload: ByteArray, qos: QoS = QoS.AT_MOST_ONCE, retain: Boolean = false): CompletableFuture<Unit>

Publish payload to topic. With retain, the broker keeps it and hands it to every later subscriber; an empty retained payload clears the topic. The future completes when the message has left (QoS 0) or been acknowledged (QoS 1/2). A QoS 0 message published while disconnected is dropped and its future fails; QoS 1 is queued and sent on reconnect.

    fun publish(topic: String, payload: ByteArray, qos: QoS = QoS.AT_MOST_ONCE, retain: Boolean = false): CompletableFuture<Unit> {
        require(MqttTopic.isValidName(topic)) { "'$topic' is not a topic you can publish to (no + or #, not empty)" }
        check(!isClosed) { "client closed" }
        val f = CompletableFuture<Unit>()
        if (retain) {
            if (payload.isEmpty()) retainedCache.remove(topic) else retainedCache[topic] = MqttMessage(topic, payload, qos, retained = true)
        }
        if (qos == QoS.AT_MOST_ONCE) {
            if (send(MqttPacket.Publish(topic, payload, qos, retain))) { sent.incrementAndGet(); f.complete(Unit) }
            else f.completeExceptionally(IOException("not connected to $url"))
            return f
        }
        val id = nextId()
        val p = MqttPacket.Publish(topic, payload, qos, retain, packetId = id)
        pendingAcks[id] = f
        if (qos == QoS.AT_LEAST_ONCE) unacked[id] = p
        if (send(p)) sent.incrementAndGet()
        return f
    }

fun publish(topic: String, payload: String, qos: QoS = QoS.AT_MOST_ONCE, retain: Boolean = false)

    fun publish(topic: String, payload: String, qos: QoS = QoS.AT_MOST_ONCE, retain: Boolean = false) =
        publish(topic, payload.encodeToByteArray(), qos, retain)

fun subscribe(filter: String, qos: QoS = QoS.AT_MOST_ONCE, handler: (MqttMessage) -> Unit): MqttSubscription {

Subscribe to filter (+ one level, # the rest). Retained matches arrive at once.

The broker sees only a cover of the local filters (MqttTopic.cover): a filter another one already subsumes (a/+ under a/#) sends no SUBSCRIBE, so the broker sends each message once and each matching handler gets it once. Such a filter's retained replay comes from the client's cache of retained messages seen, which knows only what the cover has delivered.

    fun subscribe(filter: String, qos: QoS = QoS.AT_MOST_ONCE, handler: (MqttMessage) -> Unit): MqttSubscription {
        require(MqttTopic.isValidFilter(filter)) { "'$filter' is not a valid topic filter" }
        val s = MqttSubscription(filter, qos, handler, this)
        synchronized(coverLock) {
            subscriptions.add(s)
            val sent = updateCover()
            // No SUBSCRIBE covering it went out, so the broker will not replay retained messages for it.
            // (Disconnected: the SUBSCRIBE on connect replays them.)
            val hidden = sent != null && sent.keys.none { MqttTopic.subsumes(it, filter) }
            if (hidden) retainedCache.values.filter { MqttTopic.matches(filter, it.topic) }.forEach { m ->
                dispatch(Runnable { if (!s.isClosed) { s.count.incrementAndGet(); s.handler(m) } })
            }
        }
        return s
    }

internal fun unsubscribe(s: MqttSubscription)

    internal fun unsubscribe(s: MqttSubscription) {
        if (s.isClosed) return
        s.isClosed = true
        synchronized(coverLock) {
            subscriptions.remove(s)
            updateCover()
        }
    }

Bring the broker's subscriptions to the cover of the local filters. New or upgraded filters are subscribed before superseded ones are unsubscribed, so no matching message falls in a gap (one may arrive twice during the swap). Not connected: nothing is sent; connecting resends all.

    private fun updateCover(): Map<String, QoS>? = synchronized(coverLock) {
        if (out == null) return@synchronized null
        val wanted = MqttTopic.cover(subscriptions.groupBy { it.filter }.mapValues { (_, l) -> QoS.of(l.maxOf { it.qos.value }) })
        val add = wanted.filter { (f, q) -> brokerSubs[f] != q }
        val remove = brokerSubs.keys.filter { it !in wanted }
        if (add.isNotEmpty()) {
            if (!send(MqttPacket.Subscribe(nextId(), add.toList()))) return@synchronized null
            brokerSubs.putAll(add)
        }
        if (remove.isNotEmpty()) {
            if (!send(MqttPacket.Unsubscribe(nextId(), remove))) return@synchronized null
            remove.forEach { brokerSubs.remove(it) }
        }
        add
    }

val filters: List<String> get() = subscriptions.map { it.filter }.distinct()

The live subscriptions' filters.

val brokerSubscriptions: Map<String, QoS> get() = synchronized(coverLock) { brokerSubs.toMap() }

The filters subscribed at the broker: the cover of filters.

source — 126 lines, from line 306
    private fun run() {
        var backoff = 250L
        while (!isClosed) {
            try {
                connectOnce()
                backoff = 250L
                readLoop()
            } catch (e: Exception) {
                lastError = e.message ?: e.toString()
            }
            drop(lastError ?: "connection closed")
            if (!autoReconnect || isClosed) break
            try { Thread.sleep(backoff) } catch (_: InterruptedException) { break }
            backoff = (backoff * 2).coerceAtMost(8000)
        }
    }

    private fun connectOnce() {
        val s = Socket()
        s.tcpNoDelay = true
        s.connect(InetSocketAddress(host, port), connectTimeoutMs)
        socket = s
        if (isClosed) { s.close(); return }
        val o = s.getOutputStream()
        val input = BufferedInputStream(s.getInputStream())
        s.soTimeout = connectTimeoutMs
        // [out] stays null until CONNACK: a publish or subscribe made meanwhile must not reach the
        // socket ahead of CONNECT (mosquitto drops such a client); it is queued and sent below.
        o.write(Mqtt.encode(MqttPacket.Connect(clientId, cleanSession, keepAliveSeconds, will, username, password?.encodeToByteArray())))
        o.flush()
        lastSendNanos = System.nanoTime()
        val ack = Mqtt.read(input) as? MqttPacket.ConnAck ?: throw MqttProtocolException("expected CONNACK")
        if (ack.returnCode != 0) {
            throw MqttProtocolException("broker refused: ${Mqtt.connackReason(ack.returnCode)}")
        }
        synchronized(coverLock) {
            brokerSubs.clear(); out = o
            // Queued publishes first, under the lock so no SUBSCRIBE overtakes them: a retained message
            // published before a subscribe call, while connecting, must reach the broker first, or
            // the subscription gets it live, not retained.
            unacked.values.sortedBy { it.packetId }.forEach { send(it.copy(dup = true)) }
        }
        s.soTimeout = 250 // so the reader can check the keepalive clock
        pingSentNanos = 0L
        isConnected = true
        lastError = null
        connections.incrementAndGet()
        // Subscriptions are per connection with a clean session: make the cover again.
        updateCover()
        synchronized(lock) { connectedFuture.complete(Unit) }
        val ev = MqttConnectionEvent(true, "connected to $url")
        onConnection.forEach { cb -> dispatch(Runnable { cb(ev) }) }
        this.input = input
    }

    @Volatile private var input: BufferedInputStream? = null

    private fun readLoop() {
        val input = this.input ?: return
        val keepNanos = keepAliveSeconds * 1_000_000_000L
        while (!isClosed) {
            val p = try {
                Mqtt.read(input) ?: return
            } catch (_: java.net.SocketTimeoutException) {
                null
            }
            if (keepNanos > 0) {
                val now = System.nanoTime()
                if (pingSentNanos != 0L && now - pingSentNanos > keepNanos) throw IOException("no PINGRESP within ${keepAliveSeconds}s")
                if (pingSentNanos == 0L && now - lastSendNanos > keepNanos * 3 / 4) {
                    pingSentNanos = now; send(MqttPacket.PingReq)
                }
            }
            if (p != null) handle(p)
        }
    }

    private fun handle(p: MqttPacket) {
        when (p) {
            is MqttPacket.Publish -> {
                when (p.qos) {
                    QoS.AT_MOST_ONCE -> {}
                    QoS.AT_LEAST_ONCE -> send(MqttPacket.PubAck(p.packetId))
                    QoS.EXACTLY_ONCE -> send(MqttPacket.PubRec(p.packetId))
                }
                val m = MqttMessage(p.topic, p.payload, p.qos, p.retain)
                received.incrementAndGet()
                latest[p.topic] = m
                onReceive.forEach { runCatching { it(m) } }
                if (p.retain) {
                    if (p.payload.isEmpty()) retainedCache.remove(p.topic) else retainedCache[p.topic] = m
                } else retainedCache[p.topic]?.let { old ->
                    // A live message is not marked retained even when it was published retained, so a
                    // different live payload may have replaced the retained one: forget rather than replay stale.
                    if (!old.payload.contentEquals(p.payload)) retainedCache.remove(p.topic)
                }
                subscriptions.filter { MqttTopic.matches(it.filter, p.topic) }.forEach { s ->
                    dispatch(Runnable {
                        if (!s.isClosed) { s.count.incrementAndGet(); s.handler(m) }
                    })
                }
            }
            is MqttPacket.PubAck -> { unacked.remove(p.packetId); pendingAcks.remove(p.packetId)?.complete(Unit) }
            is MqttPacket.PubRec -> send(MqttPacket.PubRel(p.packetId))
            is MqttPacket.PubComp -> pendingAcks.remove(p.packetId)?.complete(Unit)
            is MqttPacket.PubRel -> send(MqttPacket.PubComp(p.packetId))
            MqttPacket.PingResp -> pingSentNanos = 0L
            is MqttPacket.SubAck -> if (p.returnCodes.any { it == 0x80 }) lastError = "broker refused a subscription"
            else -> {}
        }
    }

    private fun drop(reason: String) {
        val s = socket ?: return
        socket = null; out = null; input = null
        try { s.close() } catch (_: IOException) {}
        synchronized(coverLock) { brokerSubs.clear() } // after close, so a writer blocked under the lock is released
        if (isConnected) {
            isConnected = false
            synchronized(lock) { connectedFuture = CompletableFuture() }
            val ev = MqttConnectionEvent(false, reason)
            onConnection.forEach { cb -> dispatch(Runnable { cb(ev) }) }
        }
        // QoS 0 waiters and QoS 2 flows cannot survive a reconnect.
        pendingAcks.keys.filter { !unacked.containsKey(it) }.forEach { pendingAcks.remove(it)?.completeExceptionally(IOException(reason)) }
    }

override fun close()

Disconnect cleanly (the broker does not publish the will) and stop reconnecting.

    override fun close() {
        if (isClosed) return
        isClosed = true
        send(MqttPacket.Disconnect)
        drop("closed")
        pendingAcks.values.forEach { it.completeExceptionally(IOException("client closed")) }
        pendingAcks.clear()
        thread.interrupt()
    }

override fun toString()

    override fun toString() = "MqttClient($url, ${if (isConnected) "connected" else "disconnected"}, ${subscriptions.size} subscriptions)"
}