@annadata/capacitor-mqtt-quic 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +399 -0
- package/android/NGTCP2_BUILD_INSTRUCTIONS.md +319 -0
- package/android/build-nghttp3.sh +182 -0
- package/android/build-ngtcp2.sh +289 -0
- package/android/build-openssl.sh +302 -0
- package/android/build.gradle +75 -0
- package/android/gradle.properties +3 -0
- package/android/proguard-rules.pro +2 -0
- package/android/settings.gradle +1 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/assets/mqttquic_ca.pem +5 -0
- package/android/src/main/cpp/CMakeLists.txt +157 -0
- package/android/src/main/cpp/ngtcp2_jni.cpp +928 -0
- package/android/src/main/kotlin/ai/annadata/mqttquic/MqttQuicPlugin.kt +232 -0
- package/android/src/main/kotlin/ai/annadata/mqttquic/client/MQTTClient.kt +339 -0
- package/android/src/main/kotlin/ai/annadata/mqttquic/mqtt/MQTT5Properties.kt +250 -0
- package/android/src/main/kotlin/ai/annadata/mqttquic/mqtt/MQTT5Protocol.kt +281 -0
- package/android/src/main/kotlin/ai/annadata/mqttquic/mqtt/MQTT5ReasonCodes.kt +109 -0
- package/android/src/main/kotlin/ai/annadata/mqttquic/mqtt/MQTTProtocol.kt +249 -0
- package/android/src/main/kotlin/ai/annadata/mqttquic/mqtt/MQTTTypes.kt +47 -0
- package/android/src/main/kotlin/ai/annadata/mqttquic/quic/NGTCP2Client.kt +184 -0
- package/android/src/main/kotlin/ai/annadata/mqttquic/quic/QuicClientStub.kt +54 -0
- package/android/src/main/kotlin/ai/annadata/mqttquic/quic/QuicTypes.kt +21 -0
- package/android/src/main/kotlin/ai/annadata/mqttquic/transport/QUICStreamAdapter.kt +37 -0
- package/android/src/main/kotlin/ai/annadata/mqttquic/transport/StreamTransport.kt +91 -0
- package/android/src/test/kotlin/ai/annadata/mqttquic/mqtt/MQTTProtocolTest.kt +92 -0
- package/dist/esm/definitions.d.ts +66 -0
- package/dist/esm/definitions.d.ts.map +1 -0
- package/dist/esm/definitions.js +2 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/index.d.ts +5 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +7 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/web.d.ts +28 -0
- package/dist/esm/web.d.ts.map +1 -0
- package/dist/esm/web.js +183 -0
- package/dist/esm/web.js.map +1 -0
- package/dist/plugin.cjs.js +217 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +215 -0
- package/dist/plugin.js.map +1 -0
- package/ios/MqttQuicPlugin.podspec +34 -0
- package/ios/NGTCP2_BUILD_INSTRUCTIONS.md +302 -0
- package/ios/Sources/MqttQuicPlugin/Client/MQTTClient.swift +343 -0
- package/ios/Sources/MqttQuicPlugin/MQTT/MQTT5Properties.swift +280 -0
- package/ios/Sources/MqttQuicPlugin/MQTT/MQTT5Protocol.swift +333 -0
- package/ios/Sources/MqttQuicPlugin/MQTT/MQTT5ReasonCodes.swift +113 -0
- package/ios/Sources/MqttQuicPlugin/MQTT/MQTTProtocol.swift +322 -0
- package/ios/Sources/MqttQuicPlugin/MQTT/MQTTTypes.swift +54 -0
- package/ios/Sources/MqttQuicPlugin/MqttQuicPlugin.swift +229 -0
- package/ios/Sources/MqttQuicPlugin/QUIC/NGTCP2Bridge.h +29 -0
- package/ios/Sources/MqttQuicPlugin/QUIC/NGTCP2Bridge.mm +865 -0
- package/ios/Sources/MqttQuicPlugin/QUIC/NGTCP2Client.swift +262 -0
- package/ios/Sources/MqttQuicPlugin/QUIC/QuicClientStub.swift +66 -0
- package/ios/Sources/MqttQuicPlugin/QUIC/QuicTypes.swift +23 -0
- package/ios/Sources/MqttQuicPlugin/Resources/mqttquic_ca.pem +5 -0
- package/ios/Sources/MqttQuicPlugin/Transport/QUICStreamAdapter.swift +50 -0
- package/ios/Sources/MqttQuicPlugin/Transport/StreamTransport.swift +105 -0
- package/ios/Tests/MQTTProtocolTests.swift +82 -0
- package/ios/build-nghttp3.sh +173 -0
- package/ios/build-ngtcp2.sh +278 -0
- package/ios/build-openssl.sh +405 -0
- package/package.json +63 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
package ai.annadata.mqttquic
|
|
2
|
+
|
|
3
|
+
import ai.annadata.mqttquic.client.MQTTClient
|
|
4
|
+
import ai.annadata.mqttquic.mqtt.MQTT5PropertyType
|
|
5
|
+
import com.getcapacitor.JSObject
|
|
6
|
+
import com.getcapacitor.Plugin
|
|
7
|
+
import com.getcapacitor.PluginCall
|
|
8
|
+
import com.getcapacitor.PluginMethod
|
|
9
|
+
import com.getcapacitor.annotation.CapacitorPlugin
|
|
10
|
+
import android.system.Os
|
|
11
|
+
import kotlinx.coroutines.CoroutineScope
|
|
12
|
+
import kotlinx.coroutines.Dispatchers
|
|
13
|
+
import kotlinx.coroutines.launch
|
|
14
|
+
import java.nio.charset.StandardCharsets
|
|
15
|
+
import java.io.File
|
|
16
|
+
import java.io.IOException
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Capacitor plugin bridge. Phase 3: connect/publish/subscribe call into MQTTClient.
|
|
20
|
+
*/
|
|
21
|
+
@CapacitorPlugin(name = "MqttQuic")
|
|
22
|
+
class MqttQuicPlugin : Plugin() {
|
|
23
|
+
|
|
24
|
+
private var client = MQTTClient(MQTTClient.ProtocolVersion.AUTO)
|
|
25
|
+
private val scope = CoroutineScope(Dispatchers.Main)
|
|
26
|
+
|
|
27
|
+
private fun bundledCaFilePath(): String? {
|
|
28
|
+
return try {
|
|
29
|
+
val assetName = "mqttquic_ca.pem"
|
|
30
|
+
val content = context.assets.open(assetName).bufferedReader().use { it.readText() }
|
|
31
|
+
if (!content.contains("BEGIN CERTIFICATE")) {
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
val outFile = File(context.filesDir, assetName)
|
|
35
|
+
outFile.writeText(content)
|
|
36
|
+
outFile.absolutePath
|
|
37
|
+
} catch (_: IOException) {
|
|
38
|
+
null
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@PluginMethod
|
|
43
|
+
fun connect(call: PluginCall) {
|
|
44
|
+
val host = call.getString("host") ?: ""
|
|
45
|
+
val port = call.getInt("port") ?: 1884
|
|
46
|
+
val clientId = call.getString("clientId") ?: ""
|
|
47
|
+
val username = call.getString("username")
|
|
48
|
+
val password = call.getString("password")
|
|
49
|
+
val cleanSession = call.getBoolean("cleanSession", true)
|
|
50
|
+
val keepalive = call.getInt("keepalive", 60)
|
|
51
|
+
val protocolVersionStr = call.getString("protocolVersion") ?: "auto"
|
|
52
|
+
val sessionExpiryInterval = call.getInt("sessionExpiryInterval")
|
|
53
|
+
val caFile = call.getString("caFile")
|
|
54
|
+
val caPath = call.getString("caPath")
|
|
55
|
+
|
|
56
|
+
val protocolVersion = when (protocolVersionStr) {
|
|
57
|
+
"5.0" -> MQTTClient.ProtocolVersion.V5
|
|
58
|
+
"3.1.1" -> MQTTClient.ProtocolVersion.V311
|
|
59
|
+
else -> MQTTClient.ProtocolVersion.AUTO
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (host.isEmpty() || clientId.isEmpty()) {
|
|
63
|
+
call.reject("host and clientId are required")
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
scope.launch {
|
|
68
|
+
try {
|
|
69
|
+
try {
|
|
70
|
+
val bundled = bundledCaFilePath()
|
|
71
|
+
when {
|
|
72
|
+
caFile != null -> Os.setenv("MQTT_QUIC_CA_FILE", caFile, true)
|
|
73
|
+
bundled != null -> Os.setenv("MQTT_QUIC_CA_FILE", bundled, true)
|
|
74
|
+
else -> Os.setenv("MQTT_QUIC_CA_FILE", "", true)
|
|
75
|
+
}
|
|
76
|
+
if (caPath != null) {
|
|
77
|
+
Os.setenv("MQTT_QUIC_CA_PATH", caPath, true)
|
|
78
|
+
} else {
|
|
79
|
+
Os.setenv("MQTT_QUIC_CA_PATH", "", true)
|
|
80
|
+
}
|
|
81
|
+
} catch (_: Exception) {
|
|
82
|
+
// Ignore env setup failures; native layer will report verification errors.
|
|
83
|
+
}
|
|
84
|
+
if (client.getState() == MQTTClient.State.CONNECTED) {
|
|
85
|
+
client.disconnect()
|
|
86
|
+
}
|
|
87
|
+
client = MQTTClient(protocolVersion)
|
|
88
|
+
client.connect(host, port, clientId, username, password, cleanSession, keepalive, sessionExpiryInterval)
|
|
89
|
+
call.resolve(JSObject().put("connected", true))
|
|
90
|
+
} catch (e: Exception) {
|
|
91
|
+
call.reject(e.message ?: "Connection failed")
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
@PluginMethod
|
|
97
|
+
fun testHarness(call: PluginCall) {
|
|
98
|
+
val host = call.getString("host") ?: ""
|
|
99
|
+
val port = call.getInt("port") ?: 1884
|
|
100
|
+
val clientId = call.getString("clientId") ?: "mqttquic_test_client"
|
|
101
|
+
val topic = call.getString("topic") ?: "test/topic"
|
|
102
|
+
val payload = call.getString("payload") ?: "Hello QUIC!"
|
|
103
|
+
val caFile = call.getString("caFile")
|
|
104
|
+
val caPath = call.getString("caPath")
|
|
105
|
+
|
|
106
|
+
if (host.isEmpty()) {
|
|
107
|
+
call.reject("host is required")
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
scope.launch {
|
|
112
|
+
try {
|
|
113
|
+
try {
|
|
114
|
+
val bundled = bundledCaFilePath()
|
|
115
|
+
when {
|
|
116
|
+
caFile != null -> Os.setenv("MQTT_QUIC_CA_FILE", caFile, true)
|
|
117
|
+
bundled != null -> Os.setenv("MQTT_QUIC_CA_FILE", bundled, true)
|
|
118
|
+
else -> Os.setenv("MQTT_QUIC_CA_FILE", "", true)
|
|
119
|
+
}
|
|
120
|
+
if (caPath != null) {
|
|
121
|
+
Os.setenv("MQTT_QUIC_CA_PATH", caPath, true)
|
|
122
|
+
} else {
|
|
123
|
+
Os.setenv("MQTT_QUIC_CA_PATH", "", true)
|
|
124
|
+
}
|
|
125
|
+
} catch (_: Exception) {
|
|
126
|
+
// Ignore env setup failures; native layer will report verification errors.
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
client = MQTTClient(MQTTClient.ProtocolVersion.AUTO)
|
|
130
|
+
client.connect(host, port, clientId, null, null, true, 60, null)
|
|
131
|
+
client.subscribe(topic, 0, null)
|
|
132
|
+
client.publish(topic, payload.toByteArray(StandardCharsets.UTF_8), 0, null)
|
|
133
|
+
client.disconnect()
|
|
134
|
+
call.resolve(JSObject().put("success", true))
|
|
135
|
+
} catch (e: Exception) {
|
|
136
|
+
call.reject(e.message ?: "Test harness failed")
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
@PluginMethod
|
|
142
|
+
fun disconnect(call: PluginCall) {
|
|
143
|
+
scope.launch {
|
|
144
|
+
try {
|
|
145
|
+
client.disconnect()
|
|
146
|
+
call.resolve()
|
|
147
|
+
} catch (e: Exception) {
|
|
148
|
+
call.reject(e.message ?: "Disconnect failed")
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
@PluginMethod
|
|
154
|
+
fun publish(call: PluginCall) {
|
|
155
|
+
val topic = call.getString("topic") ?: ""
|
|
156
|
+
val qos = call.getInt("qos", 0)
|
|
157
|
+
val messageExpiryInterval = call.getInt("messageExpiryInterval")
|
|
158
|
+
val contentType = call.getString("contentType")
|
|
159
|
+
|
|
160
|
+
if (topic.isEmpty()) {
|
|
161
|
+
call.reject("topic is required")
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
val data: ByteArray = when {
|
|
166
|
+
call.getString("payload") != null ->
|
|
167
|
+
call.getString("payload")!!.toByteArray(StandardCharsets.UTF_8)
|
|
168
|
+
call.getArray("payload") != null -> {
|
|
169
|
+
val arr = call.getArray("payload")!!
|
|
170
|
+
(0 until arr.length()).mapNotNull { i ->
|
|
171
|
+
(arr.get(i) as? Number)?.toInt()?.and(0xFF)?.toByte()
|
|
172
|
+
}.toByteArray()
|
|
173
|
+
}
|
|
174
|
+
else -> {
|
|
175
|
+
call.reject("payload must be string or number array")
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
scope.launch {
|
|
181
|
+
try {
|
|
182
|
+
val properties = mutableMapOf<Int, Any>()
|
|
183
|
+
messageExpiryInterval?.let { properties[MQTT5PropertyType.MESSAGE_EXPIRY_INTERVAL.toInt()] = it }
|
|
184
|
+
contentType?.let { properties[MQTT5PropertyType.CONTENT_TYPE.toInt()] = it }
|
|
185
|
+
client.publish(topic, data, minOf(qos, 2), if (properties.isNotEmpty()) properties else null)
|
|
186
|
+
call.resolve(JSObject().put("success", true))
|
|
187
|
+
} catch (e: Exception) {
|
|
188
|
+
call.reject(e.message ?: "Publish failed")
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
@PluginMethod
|
|
194
|
+
fun subscribe(call: PluginCall) {
|
|
195
|
+
val topic = call.getString("topic") ?: ""
|
|
196
|
+
val qos = call.getInt("qos", 0)
|
|
197
|
+
val subscriptionIdentifier = call.getInt("subscriptionIdentifier")
|
|
198
|
+
|
|
199
|
+
if (topic.isEmpty()) {
|
|
200
|
+
call.reject("topic is required")
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
scope.launch {
|
|
205
|
+
try {
|
|
206
|
+
client.subscribe(topic, minOf(qos, 2), subscriptionIdentifier)
|
|
207
|
+
call.resolve(JSObject().put("success", true))
|
|
208
|
+
} catch (e: Exception) {
|
|
209
|
+
call.reject(e.message ?: "Subscribe failed")
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
@PluginMethod
|
|
215
|
+
fun unsubscribe(call: PluginCall) {
|
|
216
|
+
val topic = call.getString("topic") ?: ""
|
|
217
|
+
|
|
218
|
+
if (topic.isEmpty()) {
|
|
219
|
+
call.reject("topic is required")
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
scope.launch {
|
|
224
|
+
try {
|
|
225
|
+
client.unsubscribe(topic)
|
|
226
|
+
call.resolve(JSObject().put("success", true))
|
|
227
|
+
} catch (e: Exception) {
|
|
228
|
+
call.reject(e.message ?: "Unsubscribe failed")
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
package ai.annadata.mqttquic.client
|
|
2
|
+
|
|
3
|
+
import ai.annadata.mqttquic.mqtt.MQTTConnAckCode
|
|
4
|
+
import ai.annadata.mqttquic.mqtt.MQTTMessageType
|
|
5
|
+
import ai.annadata.mqttquic.mqtt.MQTTProtocol
|
|
6
|
+
import ai.annadata.mqttquic.mqtt.MQTT5Protocol
|
|
7
|
+
import ai.annadata.mqttquic.mqtt.MQTT5ReasonCode
|
|
8
|
+
import ai.annadata.mqttquic.mqtt.MQTTProtocolLevel
|
|
9
|
+
import ai.annadata.mqttquic.quic.NGTCP2Client
|
|
10
|
+
import ai.annadata.mqttquic.quic.QuicClient
|
|
11
|
+
import ai.annadata.mqttquic.quic.QuicClientStub
|
|
12
|
+
import ai.annadata.mqttquic.quic.QuicStream
|
|
13
|
+
import ai.annadata.mqttquic.transport.MQTTStreamReader
|
|
14
|
+
import ai.annadata.mqttquic.transport.MQTTStreamWriter
|
|
15
|
+
import ai.annadata.mqttquic.transport.QUICStreamReader
|
|
16
|
+
import ai.annadata.mqttquic.transport.QUICStreamWriter
|
|
17
|
+
import kotlinx.coroutines.CoroutineScope
|
|
18
|
+
import kotlinx.coroutines.Dispatchers
|
|
19
|
+
import kotlinx.coroutines.isActive
|
|
20
|
+
import kotlinx.coroutines.launch
|
|
21
|
+
import kotlinx.coroutines.runBlocking
|
|
22
|
+
import kotlinx.coroutines.sync.Mutex
|
|
23
|
+
import kotlinx.coroutines.sync.withLock
|
|
24
|
+
import kotlinx.coroutines.SupervisorJob
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* High-level MQTT client: connect, publish, subscribe, disconnect.
|
|
28
|
+
* Uses QuicClient + stream adapters + MQTT protocol.
|
|
29
|
+
*/
|
|
30
|
+
class MQTTClient {
|
|
31
|
+
|
|
32
|
+
enum class State {
|
|
33
|
+
DISCONNECTED,
|
|
34
|
+
CONNECTING,
|
|
35
|
+
CONNECTED,
|
|
36
|
+
ERROR
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
enum class ProtocolVersion {
|
|
40
|
+
V311, V5, AUTO
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private var state = State.DISCONNECTED
|
|
44
|
+
private var protocolVersion = ProtocolVersion.AUTO
|
|
45
|
+
private var activeProtocolVersion: Byte = 0 // 0x04 or 0x05
|
|
46
|
+
private var quicClient: QuicClient? = null
|
|
47
|
+
private var stream: QuicStream? = null
|
|
48
|
+
private var reader: MQTTStreamReader? = null
|
|
49
|
+
private var writer: MQTTStreamWriter? = null
|
|
50
|
+
private var messageLoopJob: kotlinx.coroutines.Job? = null
|
|
51
|
+
private var nextPacketId = 1
|
|
52
|
+
private val subscribedTopics = mutableMapOf<String, (ByteArray) -> Unit>()
|
|
53
|
+
private val lock = Mutex()
|
|
54
|
+
private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
|
55
|
+
|
|
56
|
+
constructor(protocolVersion: ProtocolVersion = ProtocolVersion.AUTO) {
|
|
57
|
+
this.protocolVersion = protocolVersion
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
fun getState(): State = runBlocking { lock.withLock { state } }
|
|
61
|
+
|
|
62
|
+
suspend fun connect(
|
|
63
|
+
host: String,
|
|
64
|
+
port: Int,
|
|
65
|
+
clientId: String,
|
|
66
|
+
username: String?,
|
|
67
|
+
password: String?,
|
|
68
|
+
cleanSession: Boolean,
|
|
69
|
+
keepalive: Int,
|
|
70
|
+
sessionExpiryInterval: Int? = null
|
|
71
|
+
) {
|
|
72
|
+
lock.withLock {
|
|
73
|
+
if (state == State.CONNECTING) {
|
|
74
|
+
throw IllegalStateException("already connecting")
|
|
75
|
+
}
|
|
76
|
+
state = State.CONNECTING
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
val useV5 = protocolVersion == ProtocolVersion.V5 || protocolVersion == ProtocolVersion.AUTO
|
|
81
|
+
|
|
82
|
+
val connack: ByteArray
|
|
83
|
+
if (useV5) {
|
|
84
|
+
connack = MQTT5Protocol.buildConnackV5(MQTT5ReasonCode.SUCCESS, false)
|
|
85
|
+
} else {
|
|
86
|
+
connack = MQTTProtocol.buildConnack(MQTTConnAckCode.ACCEPTED)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
val quic: QuicClient = if (NGTCP2Client.isAvailable()) {
|
|
90
|
+
NGTCP2Client()
|
|
91
|
+
} else {
|
|
92
|
+
QuicClientStub(connack.toList())
|
|
93
|
+
}
|
|
94
|
+
quic.connect(host, port)
|
|
95
|
+
val s = quic.openStream()
|
|
96
|
+
val r = QUICStreamReader(s)
|
|
97
|
+
val w = QUICStreamWriter(s)
|
|
98
|
+
|
|
99
|
+
lock.withLock {
|
|
100
|
+
quicClient = quic
|
|
101
|
+
stream = s
|
|
102
|
+
reader = r
|
|
103
|
+
writer = w
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
val connectData: ByteArray
|
|
107
|
+
if (useV5) {
|
|
108
|
+
connectData = MQTT5Protocol.buildConnectV5(
|
|
109
|
+
clientId,
|
|
110
|
+
username,
|
|
111
|
+
password,
|
|
112
|
+
keepalive,
|
|
113
|
+
cleanSession,
|
|
114
|
+
sessionExpiryInterval
|
|
115
|
+
)
|
|
116
|
+
activeProtocolVersion = MQTTProtocolLevel.V5
|
|
117
|
+
} else {
|
|
118
|
+
connectData = MQTTProtocol.buildConnect(
|
|
119
|
+
clientId,
|
|
120
|
+
username,
|
|
121
|
+
password,
|
|
122
|
+
keepalive,
|
|
123
|
+
cleanSession
|
|
124
|
+
)
|
|
125
|
+
activeProtocolVersion = MQTTProtocolLevel.V311
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
w.write(connectData)
|
|
129
|
+
w.drain()
|
|
130
|
+
|
|
131
|
+
val fixed = r.readexactly(2)
|
|
132
|
+
val (msgType, remLen, hdrLen) = MQTTProtocol.parseFixedHeader(fixed)
|
|
133
|
+
val rest = r.readexactly(remLen)
|
|
134
|
+
val full = fixed + rest
|
|
135
|
+
if (msgType != MQTTMessageType.CONNACK) {
|
|
136
|
+
lock.withLock { state = State.ERROR }
|
|
137
|
+
throw IllegalArgumentException("expected CONNACK, got $msgType")
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (activeProtocolVersion == MQTTProtocolLevel.V5) {
|
|
141
|
+
val (_, reasonCode, _) = MQTT5Protocol.parseConnackV5(full, hdrLen)
|
|
142
|
+
if (reasonCode != MQTT5ReasonCode.SUCCESS) {
|
|
143
|
+
lock.withLock { state = State.ERROR }
|
|
144
|
+
throw IllegalArgumentException("CONNACK refused: $reasonCode")
|
|
145
|
+
}
|
|
146
|
+
} else {
|
|
147
|
+
val (_, returnCode) = MQTTProtocol.parseConnack(full, hdrLen)
|
|
148
|
+
if (returnCode != MQTTConnAckCode.ACCEPTED) {
|
|
149
|
+
lock.withLock { state = State.ERROR }
|
|
150
|
+
throw IllegalArgumentException("CONNACK refused: $returnCode")
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
lock.withLock { state = State.CONNECTED }
|
|
155
|
+
startMessageLoop()
|
|
156
|
+
} catch (e: Exception) {
|
|
157
|
+
val wr = lock.withLock {
|
|
158
|
+
val w = writer
|
|
159
|
+
quicClient = null
|
|
160
|
+
stream = null
|
|
161
|
+
reader = null
|
|
162
|
+
writer = null
|
|
163
|
+
state = State.ERROR
|
|
164
|
+
w
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
wr?.close()
|
|
168
|
+
} catch (_: Exception) { /* ignore */ }
|
|
169
|
+
throw e
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
suspend fun publish(topic: String, payload: ByteArray, qos: Int, properties: Map<Int, Any>? = null) {
|
|
174
|
+
if (getState() != State.CONNECTED) throw IllegalStateException("not connected")
|
|
175
|
+
val (w, version) = lock.withLock { writer to activeProtocolVersion }
|
|
176
|
+
if (w == null) throw IllegalStateException("no writer")
|
|
177
|
+
|
|
178
|
+
val pid: Int? = if (qos > 0) nextPacketIdUsed() else null
|
|
179
|
+
val data: ByteArray
|
|
180
|
+
if (version == MQTTProtocolLevel.V5) {
|
|
181
|
+
data = MQTT5Protocol.buildPublishV5(topic, payload, pid, qos, false, properties)
|
|
182
|
+
} else {
|
|
183
|
+
data = MQTTProtocol.buildPublish(topic, payload, pid, qos, false)
|
|
184
|
+
}
|
|
185
|
+
w.write(data)
|
|
186
|
+
w.drain()
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
suspend fun subscribe(topic: String, qos: Int, subscriptionIdentifier: Int? = null) {
|
|
190
|
+
if (getState() != State.CONNECTED) throw IllegalStateException("not connected")
|
|
191
|
+
val (r, w, version) = lock.withLock { Triple(reader, writer, activeProtocolVersion) }
|
|
192
|
+
if (r == null || w == null) throw IllegalStateException("no reader/writer")
|
|
193
|
+
|
|
194
|
+
val pid = nextPacketIdUsed()
|
|
195
|
+
val data: ByteArray
|
|
196
|
+
if (version == MQTTProtocolLevel.V5) {
|
|
197
|
+
data = MQTT5Protocol.buildSubscribeV5(pid, topic, qos, subscriptionIdentifier)
|
|
198
|
+
} else {
|
|
199
|
+
data = MQTTProtocol.buildSubscribe(pid, topic, qos)
|
|
200
|
+
}
|
|
201
|
+
w.write(data)
|
|
202
|
+
w.drain()
|
|
203
|
+
|
|
204
|
+
val fixed = r.readexactly(2)
|
|
205
|
+
val (_, remLen, hdrLen) = MQTTProtocol.parseFixedHeader(fixed)
|
|
206
|
+
val rest = r.readexactly(remLen)
|
|
207
|
+
val full = fixed + rest
|
|
208
|
+
|
|
209
|
+
if (version == MQTTProtocolLevel.V5) {
|
|
210
|
+
val (_, reasonCodes, _) = MQTT5Protocol.parseSubackV5(full, hdrLen)
|
|
211
|
+
if (reasonCodes.isNotEmpty()) {
|
|
212
|
+
val firstRC = reasonCodes[0]
|
|
213
|
+
if (firstRC != MQTT5ReasonCode.GRANTED_QOS_0 && firstRC != MQTT5ReasonCode.GRANTED_QOS_1 && firstRC != MQTT5ReasonCode.GRANTED_QOS_2) {
|
|
214
|
+
throw IllegalArgumentException("SUBACK error $firstRC")
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
} else {
|
|
218
|
+
val (_, rc, _) = MQTTProtocol.parseSuback(full, hdrLen)
|
|
219
|
+
if (rc > 0x02) throw IllegalArgumentException("SUBACK error $rc")
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
suspend fun unsubscribe(topic: String) {
|
|
224
|
+
if (getState() != State.CONNECTED) throw IllegalStateException("not connected")
|
|
225
|
+
val (r, w, version) = lock.withLock {
|
|
226
|
+
subscribedTopics.remove(topic)
|
|
227
|
+
Triple(reader, writer, activeProtocolVersion)
|
|
228
|
+
}
|
|
229
|
+
if (r == null || w == null) throw IllegalStateException("no reader/writer")
|
|
230
|
+
|
|
231
|
+
val pid = nextPacketIdUsed()
|
|
232
|
+
val data: ByteArray
|
|
233
|
+
if (version == MQTTProtocolLevel.V5) {
|
|
234
|
+
data = MQTT5Protocol.buildUnsubscribeV5(pid, listOf(topic))
|
|
235
|
+
} else {
|
|
236
|
+
data = MQTTProtocol.buildUnsubscribe(pid, listOf(topic))
|
|
237
|
+
}
|
|
238
|
+
w.write(data)
|
|
239
|
+
w.drain()
|
|
240
|
+
|
|
241
|
+
val fixed = r.readexactly(2)
|
|
242
|
+
val (_, remLen, _) = MQTTProtocol.parseFixedHeader(fixed)
|
|
243
|
+
r.readexactly(remLen)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
suspend fun disconnect() {
|
|
247
|
+
val job = messageLoopJob
|
|
248
|
+
messageLoopJob = null
|
|
249
|
+
job?.cancel()
|
|
250
|
+
job?.join()
|
|
251
|
+
|
|
252
|
+
val (w, version) = lock.withLock {
|
|
253
|
+
val wr = writer
|
|
254
|
+
val v = activeProtocolVersion
|
|
255
|
+
quicClient = null
|
|
256
|
+
stream = null
|
|
257
|
+
reader = null
|
|
258
|
+
writer = null
|
|
259
|
+
state = State.DISCONNECTED
|
|
260
|
+
activeProtocolVersion = 0
|
|
261
|
+
wr to v
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
w?.let {
|
|
265
|
+
val data: ByteArray
|
|
266
|
+
if (version == MQTTProtocolLevel.V5) {
|
|
267
|
+
data = MQTT5Protocol.buildDisconnectV5(MQTT5ReasonCode.NORMAL_DISCONNECTION_DISC)
|
|
268
|
+
} else {
|
|
269
|
+
data = MQTTProtocol.buildDisconnect()
|
|
270
|
+
}
|
|
271
|
+
try {
|
|
272
|
+
it.write(data)
|
|
273
|
+
it.drain()
|
|
274
|
+
it.close()
|
|
275
|
+
} catch (e: Exception) {
|
|
276
|
+
// Ignore errors during disconnect
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
fun onMessage(topic: String, callback: (ByteArray) -> Unit) {
|
|
282
|
+
kotlinx.coroutines.runBlocking {
|
|
283
|
+
lock.withLock {
|
|
284
|
+
subscribedTopics[topic] = callback
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private suspend fun nextPacketIdUsed(): Int = lock.withLock {
|
|
290
|
+
val pid = nextPacketId
|
|
291
|
+
nextPacketId = (nextPacketId + 1) % 65536
|
|
292
|
+
if (nextPacketId == 0) nextPacketId = 1
|
|
293
|
+
pid
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private fun startMessageLoop() {
|
|
297
|
+
messageLoopJob = scope.launch {
|
|
298
|
+
while (isActive) {
|
|
299
|
+
val r = lock.withLock { reader } ?: break
|
|
300
|
+
try {
|
|
301
|
+
val fixed = r.readexactly(2)
|
|
302
|
+
val (msgType, remLen, _) = MQTTProtocol.parseFixedHeader(fixed)
|
|
303
|
+
val rest = r.readexactly(remLen)
|
|
304
|
+
val type = (msgType.toInt() and 0xF0).toByte()
|
|
305
|
+
when (type) {
|
|
306
|
+
MQTTMessageType.PINGREQ -> {
|
|
307
|
+
val w = lock.withLock { writer }
|
|
308
|
+
w?.let {
|
|
309
|
+
val pr = MQTTProtocol.buildPingresp()
|
|
310
|
+
it.write(pr)
|
|
311
|
+
it.drain()
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
MQTTMessageType.PUBLISH -> {
|
|
315
|
+
val qos = ((msgType.toInt() shr 1) and 0x03).toByte()
|
|
316
|
+
val (topic, packetId, payload) = MQTTProtocol.parsePublish(rest, 0, qos.toInt())
|
|
317
|
+
|
|
318
|
+
val (cb, _) = lock.withLock {
|
|
319
|
+
subscribedTopics[topic] to activeProtocolVersion
|
|
320
|
+
}
|
|
321
|
+
cb?.invoke(payload)
|
|
322
|
+
|
|
323
|
+
if (qos.toInt() >= 1 && packetId != null) {
|
|
324
|
+
val w = lock.withLock { writer }
|
|
325
|
+
w?.let {
|
|
326
|
+
val puback = MQTTProtocol.buildPuback(packetId)
|
|
327
|
+
it.write(puback)
|
|
328
|
+
it.drain()
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
} catch (e: Exception) {
|
|
334
|
+
if (!isActive) break
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|