Skip to content

Commit 21e2700

Browse files
committed
Support TAK v2 + legacy v1 conversion
1 parent 8730124 commit 21e2700

7 files changed

Lines changed: 508 additions & 43 deletions

File tree

core/model/src/commonMain/kotlin/org/meshtastic/core/model/Capabilities.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,14 @@ data class Capabilities(val firmwareVersion: String?, internal val forceEnableAl
5858
/** Support for TAK (ATAK) module configuration. Supported since firmware v2.7.19. */
5959
val supportsTakConfig = atLeast(V2_7_19)
6060

61+
/**
62+
* Support for the v2 TAK port (ATAK_PLUGIN_V2 = 78) with TAKPacketV2 + zstd dictionary compression.
63+
* Supported since firmware v2.8.0. Firmware v2.7.x and earlier only support the legacy
64+
* ATAK_PLUGIN port (72) with the original TAKPacket schema (PLI + GeoChat only, no compression),
65+
* so the bridge falls back to that path for older nodes.
66+
*/
67+
val supportsTakV2 = atLeast(V2_8_0)
68+
6169
/** Support for location sharing on secondary channels. Supported since firmware v2.6.10. */
6270
val supportsSecondaryChannelLocation = atLeast(V2_6_10)
6371

@@ -72,6 +80,7 @@ data class Capabilities(val firmwareVersion: String?, internal val forceEnableAl
7280
private val V2_7_17 = DeviceVersion("2.7.17")
7381
private val V2_7_18 = DeviceVersion("2.7.18")
7482
private val V2_7_19 = DeviceVersion("2.7.19")
83+
private val V2_8_0 = DeviceVersion("2.8.0")
7584
private val V3_0_0 = DeviceVersion("3.0.0")
7685
private val UNRELEASED = DeviceVersion("9.9.9")
7786
}

core/takserver/src/commonMain/kotlin/org/meshtastic/core/takserver/TAKMeshIntegration.kt

Lines changed: 89 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,14 @@ import kotlinx.coroutines.flow.map
2727

2828
import kotlinx.coroutines.launch
2929
import okio.ByteString.Companion.toByteString
30+
import org.meshtastic.core.model.Capabilities
3031
import org.meshtastic.core.model.DataPacket
3132
import org.meshtastic.core.repository.CommandSender
3233
import org.meshtastic.core.repository.MeshConfigHandler
34+
import org.meshtastic.core.repository.NodeRepository
3335

3436
import org.meshtastic.core.repository.ServiceRepository
35-
import org.meshtastic.core.takserver.TAKPacketV2Conversion.toCoTMessage
37+
import org.meshtastic.core.takserver.TAKPacketConversion.toTAKPacket
3638
import org.meshtastic.core.takserver.TAKPacketV2Conversion.toTAKPacketV2
3739
import org.meshtastic.proto.MemberRole
3840
import 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
*/
5269
class 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
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
/*
2+
* Copyright (c) 2026 Meshtastic LLC
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
*
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
16+
*/
17+
@file:Suppress("CyclomaticComplexMethod", "ReturnCount")
18+
19+
package org.meshtastic.core.takserver
20+
21+
import co.touchlab.kermit.Logger
22+
import org.meshtastic.proto.Contact
23+
import org.meshtastic.proto.GeoChat
24+
import org.meshtastic.proto.Group
25+
import org.meshtastic.proto.MemberRole
26+
import org.meshtastic.proto.PLI
27+
import org.meshtastic.proto.Status
28+
import org.meshtastic.proto.TAKPacket
29+
import org.meshtastic.proto.Team
30+
import kotlin.random.Random
31+
import kotlin.time.Clock
32+
import kotlin.time.Duration.Companion.minutes
33+
34+
/**
35+
* Legacy v1 CoT <-> TAKPacket conversion for firmware <= 2.7.x.
36+
*
37+
* Wire format: bare protobuf-encoded [TAKPacket] on `ATAK_PLUGIN` port 72,
38+
* no zstd compression (the proto has an `is_compressed` flag but the firmware
39+
* doesn't act on it). Supports only PLI and GeoChat payloads — shape, marker,
40+
* route, casevac, emergency, and task CoT events return null and are dropped.
41+
*
42+
* For the SDK-backed path that handles all payload types with zstd dictionary
43+
* compression on `ATAK_PLUGIN_V2` port 78, see [TAKPacketV2Conversion].
44+
*
45+
* [TAKMeshIntegration] picks between the two paths based on
46+
* `Capabilities.supportsTakV2` (firmware >= 2.8.0).
47+
*/
48+
object TAKPacketConversion {
49+
50+
fun CoTMessage.toTAKPacket(): TAKPacket? {
51+
val group =
52+
this.group?.let {
53+
Group(
54+
role = MemberRole.fromValue(TakConversionHelpers.getMemberRoleValue(it.role))
55+
?: MemberRole.Unspecifed,
56+
team = Team.fromValue(TakConversionHelpers.getTeamValue(it.name)) ?: Team.Unspecifed_Color,
57+
)
58+
}
59+
60+
val status = this.status?.let { Status(battery = it.battery.coerceAtLeast(0)) }
61+
62+
if (type.startsWith("a-f-G") || type.startsWith("a-f-g")) {
63+
return createPliPacket(group, status)
64+
}
65+
66+
if (type == "b-t-f") {
67+
return createChatPacket(group, status)
68+
}
69+
70+
Logger.w { "Cannot convert CoT to TAKPacket for type $type" }
71+
return null
72+
}
73+
74+
private fun CoTMessage.createPliPacket(group: Group?, status: Status?): TAKPacket {
75+
val contact = this.contact?.let { Contact(callsign = it.callsign, device_callsign = this.uid) }
76+
val pli =
77+
PLI(
78+
latitude_i = (latitude * TAK_COORDINATE_SCALE).toInt(),
79+
longitude_i = (longitude * TAK_COORDINATE_SCALE).toInt(),
80+
altitude = if (hae >= TAK_UNKNOWN_POINT_VALUE || hae.isNaN()) 0 else hae.toInt(),
81+
speed = track?.speed?.coerceAtLeast(0.0)?.toInt() ?: 0,
82+
course = track?.course?.coerceAtLeast(0.0)?.toInt() ?: 0,
83+
)
84+
85+
return TAKPacket(is_compressed = false, contact = contact, group = group, status = status, pli = pli)
86+
}
87+
88+
private fun CoTMessage.createChatPacket(group: Group?, status: Status?): TAKPacket? {
89+
val localChat = this.chat ?: return null
90+
val chatMsg = localChat.message
91+
var toUid: String? = null
92+
var toCallsign: String? = null
93+
94+
val actualDeviceUid = this.uid.geoChatSenderUid()
95+
val messageId =
96+
if (this.uid.startsWith("GeoChat.")) {
97+
this.uid.geoChatMessageId()
98+
} else {
99+
Random.nextInt().toString(TAK_HEX_RADIX)
100+
}
101+
102+
val contact =
103+
this.contact?.let {
104+
val smuggledCallsign =
105+
if (actualDeviceUid.isNotEmpty()) {
106+
"$actualDeviceUid|$messageId"
107+
} else {
108+
it.endpoint ?: ""
109+
}
110+
Contact(callsign = it.callsign, device_callsign = smuggledCallsign)
111+
}
112+
113+
if (localChat.chatroom.startsWith(this.uid) || this.uid.startsWith("GeoChat")) {
114+
val parts = this.uid.split(".")
115+
if (parts.size >= TAK_DIRECT_MESSAGE_PARTS_MIN && parts[0] == "GeoChat") {
116+
toUid = localChat.chatroom
117+
}
118+
} else if (localChat.chatroom != "All Chat Rooms") {
119+
toCallsign = localChat.chatroom
120+
}
121+
122+
val chat =
123+
GeoChat(
124+
message = chatMsg,
125+
to = toUid ?: if (toCallsign == null) "All Chat Rooms" else null,
126+
to_callsign = toCallsign,
127+
)
128+
129+
return TAKPacket(is_compressed = false, contact = contact, group = group, status = status, chat = chat)
130+
}
131+
132+
fun TAKPacket.toCoTMessage(): CoTMessage? {
133+
val rawDeviceCallsign = contact?.device_callsign ?: "UNKNOWN"
134+
val senderCallsign = contact?.callsign ?: "UNKNOWN"
135+
val timeNow = Clock.System.now()
136+
val staleTime = timeNow + DEFAULT_TAK_STALE_MINUTES.minutes
137+
138+
val (senderUid, messageId) = TakConversionHelpers.parseDeviceCallsign(rawDeviceCallsign)
139+
140+
val localPli = pli
141+
if (localPli != null) {
142+
return CoTMessage.pli(
143+
uid = senderUid,
144+
callsign = senderCallsign,
145+
latitude = localPli.latitude_i.toDouble() / TAK_COORDINATE_SCALE,
146+
longitude = localPli.longitude_i.toDouble() / TAK_COORDINATE_SCALE,
147+
altitude = localPli.altitude.toDouble(),
148+
speed = localPli.speed.toDouble(),
149+
course = localPli.course.toDouble(),
150+
team = TakConversionHelpers.teamToColorName(group?.team),
151+
role = TakConversionHelpers.roleToName(group?.role),
152+
battery = status?.battery ?: DEFAULT_TAK_BATTERY,
153+
staleMinutes = DEFAULT_TAK_STALE_MINUTES,
154+
)
155+
}
156+
157+
val localChat = chat
158+
if (localChat != null) {
159+
val chatroom =
160+
if (localChat.to != null || localChat.to_callsign != null) {
161+
localChat.to_callsign ?: localChat.to ?: "Direct Message"
162+
} else {
163+
"All Chat Rooms"
164+
}
165+
166+
val msgId = messageId ?: Random.nextInt().toString(TAK_HEX_RADIX)
167+
168+
return CoTMessage(
169+
uid = "GeoChat.$senderUid.$chatroom.$msgId",
170+
type = "b-t-f",
171+
how = "h-g-i-g-o",
172+
time = timeNow,
173+
start = timeNow,
174+
stale = staleTime,
175+
latitude = 0.0,
176+
longitude = 0.0,
177+
contact = CoTContact(callsign = senderCallsign, endpoint = DEFAULT_TAK_ENDPOINT),
178+
group = CoTGroup(name = TakConversionHelpers.teamToColorName(group?.team), role = TakConversionHelpers.roleToName(group?.role)),
179+
status = CoTStatus(battery = status?.battery ?: DEFAULT_TAK_BATTERY),
180+
chat = CoTChat(chatroom = chatroom, senderCallsign = senderCallsign, message = localChat.message),
181+
)
182+
}
183+
184+
return null
185+
}
186+
}

0 commit comments

Comments
 (0)