diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MqttJsonPayload.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MqttJsonPayload.kt index e6a6929c01..ceea81aa72 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MqttJsonPayload.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/MqttJsonPayload.kt @@ -18,6 +18,10 @@ package org.meshtastic.core.model import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.JsonTransformingSerializer @Serializable data class MqttJsonPayload( @@ -25,10 +29,17 @@ data class MqttJsonPayload( val from: Long, val to: Long? = null, val channel: Int? = null, - val payload: String? = null, + @Serializable(with = MqttPayloadStringSerializer::class) val payload: String? = null, @SerialName("hop_limit") val hopLimit: Int? = null, val id: Long? = null, val time: Long? = null, val sender: String? = null, // Add other fields as needed for position/telemetry ) + +// Firmware and MQTT bridges send "payload" as a string for text messages but as a nested JSON +// object for position/telemetry/map reports; coerce non-strings to their compact JSON text. +private object MqttPayloadStringSerializer : JsonTransformingSerializer(String.serializer()) { + override fun transformDeserialize(element: JsonElement): JsonElement = + if (element is JsonPrimitive && element.isString) element else JsonPrimitive(element.toString()) +} diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/MqttJsonPayloadTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/MqttJsonPayloadTest.kt new file mode 100644 index 0000000000..717c04f541 --- /dev/null +++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/MqttJsonPayloadTest.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.model + +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** Locks down the tolerant "payload" decoding: string, nested object, and null/absent must all parse. */ +class MqttJsonPayloadTest { + + // Matches the decode-relevant MQTTRepositoryImpl config. + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun string_payload_decodes_unchanged() { + val jsonStr = + """{"type":"text","from":12345678,"to":4294967295,"payload":"Hello World","hop_limit":3,"id":123}""" + val decoded = json.decodeFromString(jsonStr) + + assertEquals("text", decoded.type) + assertEquals(12345678L, decoded.from) + assertEquals("Hello World", decoded.payload) + } + + @Test + fun object_payload_decodes_to_compact_json_text() { + val jsonStr = + """{"type":"position","from":12345678,"payload":{"latitude_i":123456789,"longitude_i":-987654321,"time":1600000000}}""" + val decoded = json.decodeFromString(jsonStr) + + assertEquals("position", decoded.type) + assertEquals("""{"latitude_i":123456789,"longitude_i":-987654321,"time":1600000000}""", decoded.payload) + } + + @Test + fun null_payload_decodes_to_null() { + val decoded = json.decodeFromString("""{"type":"text","from":1,"payload":null}""") + assertNull(decoded.payload) + } + + @Test + fun absent_payload_decodes_to_null() { + val decoded = json.decodeFromString("""{"type":"text","from":1}""") + assertNull(decoded.payload) + } + + @Test + fun array_payload_decodes_to_compact_json_text() { + val decoded = json.decodeFromString("""{"type":"text","from":1,"payload":[1,2]}""") + assertEquals("[1,2]", decoded.payload) + } + + @Test + fun non_string_primitive_payload_is_coerced_to_text() { + val decoded = json.decodeFromString("""{"type":"text","from":1,"payload":42}""") + assertEquals("42", decoded.payload) + } + + @Test + fun string_payload_round_trips_as_string() { + val encoded = + json.encodeToString(MqttJsonPayload.serializer(), MqttJsonPayload(type = "text", from = 1, payload = "hi")) + assertTrue(encoded.contains(""""payload":"hi"""")) + } + + @Test + fun garbage_input_still_fails() { + assertFailsWith { json.decodeFromString("""{"from":"not json""") } + } +}