core:service is the central app-orchestration layer: MeshServiceOrchestrator
is constructed by both androidApp's MeshService.kt and desktopApp's
Main.kt, and will be needed by a future web client too -- it can't be
wholesale-excluded the way a leaf feature could be.
Its only web-hostile dependency was two constructor parameters typed
directly against core:takserver: TAKServerManager (already an
interface) and TAKMeshIntegration (a concrete class). core:takserver
can never get a wasmJs target -- its production implementation is a
TLS SSLServerSocket *listener* accepting inbound ATAK/iTAK
connections, and a browser sandbox can never accept inbound
connections at all, a more fundamental impossibility than the
outbound-only TCP case already excluded for MQTT.
MeshServiceOrchestrator only ever read takServerManager.isRunning and
called takMeshIntegration.start()/stop() -- the entire interaction
surface. Following this codebase's own convention (core:repository
hosts portable interfaces, core:*Impl-style modules hold the
platform-coupled implementation), a new minimal TakServerIntegration
interface in core:repository folds those three members into one seam.
TAKMeshIntegration now implements it directly; isRunning delegates to
the real takServerManager.isRunning (its own internal start/stop
re-entrancy latch, previously also named isRunning, is renamed to
isRunningState to keep the two states distinct). Its Koin provider
binds under both types (`@Single(binds = [TAKMeshIntegration::class,
TakServerIntegration::class])`) so feature/settings' debug UI still
resolves the concrete class while core:service resolves only the
interface -- verified sound against Koin's own K2-compiler-plugin
binding model via a real KoinVerificationTest run, not assumed.
core:service's dependency on core:takserver is removed entirely
(confirmed via grep: nothing else in the module referenced it).
wasmJs gets a real, honest no-op TakServerIntegration -- isRunning
always false, start/stop are no-ops -- documented as a permanent
platform impossibility, not a stand-in for future work.
androidApp/desktopApp needed zero changes: both already register
core:takserver's own Koin module directly, which still supplies the
real implementation there.
Three of six commonTest files move to a new nonWebTest source set,
for two unrelated, both-confirmed-empirically reasons:
SharedRadioInterfaceServiceLivenessTest.kt depends on core:testing (no
wasmJs target, the same gap every KMP module's test suite has hit this
session); RadioControllerImplTest.kt and RadioControllerRestoreTest.kt
crash the Kotlin/Wasm compiler ("Serialization of IrErrorType is not
supported anymore") when constructing a real RadioControllerImpl --
bisected to exactly these two files, which both differ from the four
that pass by constructing that class (interface delegation via `by`
plus Lazy<T> constructor params) -- a genuine backend limitation, not
a library gap, logged as deferred rather than worked around.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
:core:takserver
Overview
The :core:takserver module implements the Meshtastic ↔ TAK (Team Awareness Kit) bridge. It embeds an mTLS TCP server (port 8089) compatible with ATAK (Android), iTAK (iOS), and WinTAK clients, enabling mesh-networked position sharing and GeoChat with TAK-enabled devices.
Targets: Android · JVM (Desktop) · iOS — fully multiplatform with expect/actual splits for compression, file I/O, and the TCP server itself.
Key Responsibilities
- Serve an mTLS TCP listener (port 8089) compatible with the CoT (Cursor-on-Target) protocol
- Convert Meshtastic protobuf packets (
TAKPacketV2) to CoT XML events and vice versa - Generate ATAK Data Package
.zipexports (team contacts, map overlays) - Compress CoT payloads using Zstd (TAK SDK format) with
expect/actualplatform implementations - Buffer up to 50 CoT messages for 5 minutes when no TAK clients are connected; drain on reconnect
- Provide Crowdin-localised TAK preference XML for ATAK client provisioning
Source Structure
src/
├── commonMain/kotlin/org/meshtastic/core/takserver/
│ ├── TAKServer.kt ← interface + expect createTAKServer()
│ ├── TAKServerManager.kt ← interface + TAKServerManagerImpl (offline queue)
│ ├── TAKMeshIntegration.kt ← bridges mesh service ↔ TAK server
│ ├── CoTConversion.kt ← Position/User → CoTMessage extension fns
│ ├── CoTXml.kt / CoTXmlParser.kt / CoTXmlFrameBuffer.kt
│ ├── CoTXmlDataClasses.kt
│ ├── CoTDetailStripper.kt
│ ├── TAKModels.kt ← CoTMessage, TAKClientInfo, TAKConnectionEvent
│ ├── TAKPacketConversion.kt
│ ├── TAKPacketV2Conversion.kt
│ ├── TAKDefaults.kt
│ ├── TAKDataPackageGenerator.kt
│ ├── RouteDataPackageGenerator.kt
│ ├── TAKPrefXmlDataClasses.kt
│ ├── TakV2TypeMapper.kt
│ ├── TakConversionHelpers.kt
│ ├── XmlUtils.kt
│ ├── AtakFileWriter.kt ← expect
│ ├── TakSdkCompressor.kt ← expect (Zstd TAK-SDK frame)
│ ├── TakV2Compressor.kt ← expect (Zstd TAKPacketV2 frame)
│ ├── ZipArchiver.kt ← expect
│ ├── TakFixtureLoader.kt ← expect (test fixtures)
│ ├── TakMeshTestRunner.kt
│ └── di/
│ └── CoreTakServerModule.kt
├── jvmAndroidMain/kotlin/ ← actual TAKServerJvm, TAKClientConnection, TakCertLoader
├── androidMain/kotlin/ ← actual AtakFileWriter (Android)
├── jvmMain/kotlin/ ← actual AtakFileWriter (Desktop), XML pull-parser
└── iosMain/kotlin/ ← actual TAKServerIos, actual compression impls
Notable APIs
TAKServer (interface)
interface TAKServer {
val connectionCount: StateFlow<Int>
var onMessage: ((CoTMessage, TAKClientInfo?) -> Unit)?
var onClientConnected: (() -> Unit)?
suspend fun start(scope: CoroutineScope): Result<Unit>
fun stop()
suspend fun broadcast(cotMessage: CoTMessage)
suspend fun broadcastRawXml(xml: String)
suspend fun hasConnections(): Boolean
}
The mTLS listener binds on port 8089 using a bundled server.p12 / ca.pem identity, compatible with the ATAK Data Package provisioning flow.
TAKServerManager (interface)
interface TAKServerManager {
val isRunning: StateFlow<Boolean>
val connectionCount: StateFlow<Int>
val inboundMessages: SharedFlow<InboundCoTMessage>
suspend fun start(scope: CoroutineScope)
fun stop()
suspend fun broadcast(cotMessage: CoTMessage)
suspend fun broadcastRawXml(xml: String)
}
TAKServerManagerImpl adds an offline queue: buffers up to 50 CoT messages for 5 minutes when no clients are connected and drains them automatically on the next onClientConnected callback.
CoTMessage
@Serializable
data class CoTMessage(
val uid: String,
val type: String, // e.g. "a-f-G-U-C" (friendly ground unit)
val time: Instant,
val lat: Double, val lon: Double, val hae: Double,
val contact: CoTContact?,
val group: CoTGroup?,
val track: CoTTrack?,
val chat: CoTChat?,
val remarks: String?,
// ...
)
// Factory helpers
CoTMessage.pli(uid, callsign, lat, lon, ...) // Position Location Information
CoTMessage.chat(senderUid, callsign, message, chatroom)
CoT Conversion
// Meshtastic proto → CoT
org.meshtastic.proto.Position.toCoTMessage(uid, callsign, team, role, battery): CoTMessage
org.meshtastic.proto.User.toCoTMessage(position, team, role, battery): CoTMessage
Dependency Graph
core:takserver
├── api → core:repository (exported)
├── core:common, core:di, core:model, org.meshtastic:protobufs (Maven)
├── okio, kotlinx.serialization.json
├── xmlutil-core, xmlutil-serialization
├── ktor-client-core, ktor-network (TCP socket)
└── kotlinx.datetime, kermit (zstd rides on the SDK's transitive kzstd)
Local TAK Server Feature
The Local TAK Server can be enabled from the app's Settings screen. When running, ATAK/iTAK clients on the same network can connect to <device-ip>:8089 and their position reports are automatically bridged onto the mesh. CoT arriving from the mesh on ports 72/78 is forwarded to every connected TAK client.
Mesh to CoT (node contacts)
Separately opt-in (TakPrefs.isMeshToCotEnabled, default off, shown as "Mesh to CoT Converter" under the server toggle). When enabled alongside the server, MeshToCotBroadcaster synthesizes a CoT contact for each node in the node database so regular Meshtastic nodes appear on the ATAK map without the legacy Meshtastic TAK Plugin — which cannot work at all since the AIDL API was removed in app 2.8.0.
Nodes qualify when they have identified themselves, were heard inside the online window (2 h), and hold a valid position; the local node is excluded because ATAK renders it as self.
Output is aligned against Meshtastic-Apple's TAKMeshtasticBridge.createCoTFromNode (verified by reading that source, not inferred) so the same physical node presents identically on both platforms:
| Field | Value | Notes |
|---|---|---|
uid |
MESHTASTIC-%08X |
Upper-case hex is load-bearing. ATAK keys contacts by UID and compares case-sensitively; lower-casing it makes an Android-bridged node a separate contact from the same iOS-bridged node, so the mesh appears duplicated when both phones bridge one TAK network. |
callsign |
SHORT - Long Name |
Falls back through whichever names are populated. |
| team / role | Green / Team Member |
Remote nodes never report a TAK team. |
| stale | 15 min | Paired with the 5-min refresh below. |
remarks |
Battery … | Voltage … | Chan Util … | Air Util Tx … | RSSI … | SNR … |
Labels, order, and precision match Apple (voltage at two decimals, the rest at one). |
Two deliberate divergences from Apple, both in remarks:
- Zero is reported, not suppressed. Apple gates each field on a non-zero value (
if voltage > 0,if rssi != 0, …) and substitutes 100% for an unreported battery. Here, absence is detected via nullability and the SNR/RSSI sentinels instead — 0 dB SNR and 0 dBm RSSI are real measurements, and 0% battery is precisely the reading an operator needs to see rather than have hidden. Air Util Txis additive — no Apple counterpart.
Node.validPosition (the repo-wide helper) also requires both coordinates non-zero and in range, where Apple accepts either being non-zero; a node sitting exactly on the equator or prime meridian is therefore dropped here. Kept for consistency with every other position filter in the codebase.
Nothing on this path crosses the mesh, so none of it is subject to the LoRa MTU or the TAKPacket wire format. Three behaviours are load-bearing: broadcasts are suppressed while no client is attached (they would otherwise evict real mesh CoT from the 50-entry offline queue), a connecting client triggers a full replay, and every node is re-sent periodically so stationary markers do not expire at MESH_NODE_STALE_MINUTES.
TAKPacket-SDK consumer & version-bump playbook
This module consumes the external TAKPacket-SDK (org.meshtastic:takpacket-sdk, KMP since 0.7.0; pinned as takpacket-sdk in gradle/libs.versions.toml, currently 0.8.0) for the V2 wire format. The SDK does CoT-XML ↔ TAKPacketV2 ↔ zstd-compressed bytes; it owns the dictionaries and the schema. The TAKPacketV2 proto types themselves come from the org.meshtastic:protobufs Maven artifact (pinned as meshtastic-protobufs, api()-exported by :core:model).
Two V2 wire paths — keep both in mind when the SDK changes:
- Path A (primary, SDK-delegated):
TakSdkCompressor/TakV2Compressorcall the SDK's parser/builder/compressor. This path is insulated from proto field renames as long as the SDK andmeshtastic-protobufsversions are bumped together. - Path B (fallback):
TAKPacketV2Conversion.ktbuilds and reads the Wire-generatedTAKPacketV2directly (SDK-failure send fallback; iOS receive stub). It references proto fields by name, so it breaks at compile time on any schema change and must be updated in lockstep.
When bumping to a new (wire-breaking) SDK version:
gradle/libs.versions.toml→ bumptakpacket-sdk(and, if the schema moved,meshtastic-protobufsto the matching protobufs release).- Leave
:core:model's exclude block intact (core/model/build.gradle.kts): the SDK still declares a transitive, olderorg.meshtastic:protobufspin, so:core:modelapi()-exports the SDK withexclude(group = "org.meshtastic", module = "protobufs" / "protobufs-jvm" / "protobufs-android")— that keeps the app's single protobufs version authoritative and prevents duplicate-class / proto-ABI breakage. (The.toString()string-notation there is load-bearing: catalog dependencies are immutable, soexclude {}only works on the string copy.) - Update Path B (
TAKPacketV2Conversion.kt) and the bridge (TakV2Compressor.kt) for any renamed/removed/added wire fields. - Test:
./gradlew :core:takserver:allTests :core:takserver:compileKotlinJvm(full KMP validation;:core:takserver:jvmTestworks as a faster focused check) — against a locally published SDK add-PuseMavenLocal(gated insettings.gradle.kts); against a published version add--refresh-dependenciesinstead.
Wire facts (don't re-introduce phantom changes): PLI is implicit — no payload variant + an a-f-* cot type is a PLI. DrawnShape vertices are two packed repeated sint32 delta columns. course stays deg×100, uid stays a string, stale_seconds stays tag 16 — deliberate; do not "fix" them in TAKPacketV2Conversion.kt.
Debug "Send Test CoTs": TakMeshTestRunner sends the bundled tak_test_fixtures/*.xml through the SDK path (parse → strip → compress → send). They ride the SDK path, so they need no edits across wire breaks — they ARE the regression surface.
Dependency Graph
graph TB
:core:takserver[takserver]:::kmp-library
:core:takserver --> :core:repository
:core:takserver -.-> :core:common
:core:takserver -.-> :core:di
:core:takserver -.-> :core:model
:core:takserver -.-> :core:testing
classDef android-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-application-compose fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef compose-desktop-application fill:#CAFFBF,stroke:#000,stroke-width:2px,color:#000;
classDef android-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef android-library fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-library-compose fill:#9BF6FF,stroke:#000,stroke-width:2px,color:#000;
classDef android-test fill:#A0C4FF,stroke:#000,stroke-width:2px,color:#000;
classDef jvm-library fill:#BDB2FF,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-feature fill:#FFD6A5,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library-compose fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef kmp-library fill:#FFC1CC,stroke:#000,stroke-width:2px,color:#000;
classDef unknown fill:#FFADAD,stroke:#000,stroke-width:2px,color:#000;