@@ -27,12 +27,14 @@ import kotlinx.coroutines.flow.map
2727
2828import kotlinx.coroutines.launch
2929import okio.ByteString.Companion.toByteString
30+ import org.meshtastic.core.model.Capabilities
3031import org.meshtastic.core.model.DataPacket
3132import org.meshtastic.core.repository.CommandSender
3233import org.meshtastic.core.repository.MeshConfigHandler
34+ import org.meshtastic.core.repository.NodeRepository
3335
3436import org.meshtastic.core.repository.ServiceRepository
35- import org.meshtastic.core.takserver.TAKPacketV2Conversion.toCoTMessage
37+ import org.meshtastic.core.takserver.TAKPacketConversion.toTAKPacket
3638import org.meshtastic.core.takserver.TAKPacketV2Conversion.toTAKPacketV2
3739import org.meshtastic.proto.MemberRole
3840import org.meshtastic.proto.MeshPacket
@@ -46,15 +48,31 @@ import kotlin.time.Duration.Companion.minutes
4648/* *
4749 * Bidirectional bridge between the local TAK server and the Meshtastic mesh network.
4850 *
49- * V2 protocol only: All traffic uses port 78 (ATAK_PLUGIN_V2).
50- * Legacy V1 port 72 is still received for backward compatibility but will be removed.
51+ * Outbound traffic (TAK client -> mesh) is version-gated on the connected radio's
52+ * firmware version, exposed via [Capabilities.supportsTakV2]:
53+ *
54+ * - Firmware **>= 2.8.0**: TAKPacketV2 on port 78 (ATAK_PLUGIN_V2) with zstd
55+ * dictionary compression via TAKPacket-SDK. Supports all CoT payload types
56+ * (PLI, GeoChat, DrawnShape, Marker, Route, Aircraft, Casevac, Emergency, Task)
57+ * with compact typed encodings that fit under the 237B LoRa MTU.
58+ *
59+ * - Firmware **<= 2.7.x**: Legacy [TAKPacket] on port 72 (ATAK_PLUGIN) with bare
60+ * protobuf encoding. Supports only PLI and GeoChat — shapes, markers, routes,
61+ * and other typed CoT events are dropped (with a warning) because the legacy
62+ * schema cannot represent them.
63+ *
64+ * Inbound traffic (mesh -> TAK client) is always dual-path tolerant — both port 72
65+ * and port 78 are dispatched regardless of the local radio's firmware version, so
66+ * a v2-capable node can still relay legacy v1 packets received from older nodes
67+ * in mixed-firmware mesh deployments.
5168 */
5269class TAKMeshIntegration (
5370 private val takServerManager : TAKServerManager ,
5471 private val commandSender : CommandSender ,
5572
5673 private val serviceRepository : ServiceRepository ,
5774 private val meshConfigHandler : MeshConfigHandler ,
75+ private val nodeRepository : NodeRepository ,
5876) {
5977 @Volatile private var isRunning = false
6078 private val jobs = mutableListOf<Job >()
@@ -113,7 +131,9 @@ class TAKMeshIntegration(
113131 )
114132
115133 jobs.addAll(newJobs)
116- Logger .i { " TAK Mesh Integration started (v2 protocol)" }
134+ val fw = nodeRepository.myNodeInfo.value?.firmwareVersion
135+ val proto = if (Capabilities (fw).supportsTakV2) " v2 (port 78, zstd)" else " v1 (port 72, legacy)"
136+ Logger .i { " TAK Mesh Integration started — firmware=$fw , outbound=$proto " }
117137 }
118138
119139 fun stop () {
@@ -128,7 +148,34 @@ class TAKMeshIntegration(
128148
129149 // ── Send: TAK client → mesh ─────────────────────────────────────────────
130150
151+ /* *
152+ * Determine the outbound TAK protocol version based on the connected radio's
153+ * firmware version. Evaluated per-send (not cached) so the bridge picks up
154+ * firmware upgrades during a session without restart. If the firmware
155+ * version is unavailable (radio not yet handshook), default to V2 — the
156+ * v2 firmware was released widely enough that defaulting to legacy would
157+ * be a regression for the common case.
158+ */
159+ private fun useTakV2 (): Boolean {
160+ val fw = nodeRepository.myNodeInfo.value?.firmwareVersion ? : return true
161+ return Capabilities (fw).supportsTakV2
162+ }
163+
131164 private suspend fun sendCoTToMesh (cotMessage : CoTMessage ) {
165+ if (useTakV2()) {
166+ sendCoTToMeshV2(cotMessage)
167+ } else {
168+ sendCoTToMeshV1(cotMessage)
169+ }
170+ }
171+
172+ /* *
173+ * v2 send path (firmware >= 2.8.0): SDK parser + zstd dictionary compression,
174+ * full typed payload support (DrawnShape, Marker, Route, Aircraft, Casevac,
175+ * Emergency, Task, plus PLI / GeoChat). Wire format: `[flags byte][zstd-compressed
176+ * TAKPacketV2 protobuf]` on port 78 (ATAK_PLUGIN_V2).
177+ */
178+ private suspend fun sendCoTToMeshV2 (cotMessage : CoTMessage ) {
132179 // Prefer the sourceEventXml for shape/marker/route types — the SDK's
133180 // CotXmlParser extracts compact typed payloads (DrawnShape, Marker,
134181 // Route, etc.) that compress far better than raw_detail encoding.
@@ -142,8 +189,6 @@ class TAKMeshIntegration(
142189 // Strip non-essential elements before compression to save wire bytes
143190 val xml = stripNonEssentialElements(freshXml)
144191
145- // Logger.d { "RAW CoT OUT (mesh, ${cotMessage.type}): $rawXml" }
146-
147192 // Route through the SDK parser/compressor which handles all typed
148193 // payloads (DrawnShape, Marker, Route, Aircraft, etc.) with compact
149194 // proto fields instead of raw_detail XML. Falls back to the app's
@@ -199,6 +244,44 @@ class TAKMeshIntegration(
199244 }
200245 }
201246
247+ /* *
248+ * Legacy v1 send path (firmware <= 2.7.x): bare protobuf-encoded [TAKPacket]
249+ * on port 72 (ATAK_PLUGIN), no zstd compression. Only PLI and GeoChat
250+ * payloads are supported by the v1 schema — shapes, markers, routes,
251+ * casevac, emergency, and task CoT events are dropped with a warning.
252+ */
253+ private suspend fun sendCoTToMeshV1 (cotMessage : CoTMessage ) {
254+ val takPacket = cotMessage.toTAKPacket() ? : run {
255+ Logger .w {
256+ " Dropping CoT for legacy v1 radio: type=${cotMessage.type} not representable " +
257+ " in v1 TAKPacket schema (only PLI and GeoChat are supported). " +
258+ " Upgrade radio firmware to >= 2.8.0 for full payload support."
259+ }
260+ return
261+ }
262+
263+ val wirePayload = TAKPacket .ADAPTER .encode(takPacket)
264+ if (wirePayload.size > MAX_TAK_WIRE_PAYLOAD_BYTES ) {
265+ Logger .w {
266+ " Dropping oversized v1 TAK packet: type=${cotMessage.type} " +
267+ " size=${wirePayload.size} B max=$MAX_TAK_WIRE_PAYLOAD_BYTES "
268+ }
269+ return
270+ }
271+
272+ try {
273+ val dataPacket = DataPacket (
274+ to = DataPacket .ID_BROADCAST ,
275+ bytes = wirePayload.toByteString(),
276+ dataType = PortNum .ATAK_PLUGIN .value,
277+ )
278+ commandSender.sendData(dataPacket)
279+ Logger .d { " Sent V1 to mesh: ${cotMessage.type} (${wirePayload.size} bytes)" }
280+ } catch (e: Throwable ) {
281+ Logger .e(e) { " Failed to send v1 TAKPacket to mesh (${cotMessage.type} , ${wirePayload.size} bytes): ${e.message} " }
282+ }
283+ }
284+
202285 /* *
203286 * Wrap a [org.meshtastic.proto.TAKPacketV2] into the uncompressed v2 wire format:
204287 * `[0xFF flag byte][raw protobuf]`. Used as a fallback when the zstd native lib
0 commit comments