fix(takserver): route TAK self-test through the real v1/v2 dispatch path (#6746)

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
James RichandClaude Sonnet 5 authored and GitHub committed 2026-08-17 16:10:57 +00:00
1 parent 7bc6722510
commit 65a1f5ce4d
11 files changed
+451 -100

No files matched your search

+4 -1
View File
@@ -1699,9 +1699,12 @@ tak_server_status_unavailable
tak_server_status_waiting
tak_server_test_card_title
tak_server_test_idle
tak_server_test_protocol_v1_label
tak_server_test_protocol_v2_label
tak_server_test_result_bytes
tak_server_test_result_expected_drop
tak_server_test_result_unknown_error
tak_server_test_results
tak_server_test_results_v2
tak_server_test_run
tak_server_test_running
tak_team
@@ -1747,9 +1747,12 @@
<string name="tak_server_status_waiting">Listening on 127.0.0.1:8089 — waiting for ATAK</string>
<string name="tak_server_test_card_title">TAK Mesh Test (Debug)</string>
<string name="tak_server_test_idle">Send all %1$d test fixtures to mesh</string>
<string name="tak_server_test_protocol_v1_label">V1 (legacy firmware &lt; 2.8.0 — PLI + chat only)</string>
<string name="tak_server_test_protocol_v2_label">V2 (firmware ≥ 2.8.0)</string>
<string name="tak_server_test_result_bytes">%1$dB ✓</string>
<string name="tak_server_test_result_expected_drop">Not in v1 schema (expected)</string>
<string name="tak_server_test_result_unknown_error"></string>
<string name="tak_server_test_results">%1$d passed, %2$d failed of %3$d/%4$d</string>
<string name="tak_server_test_results_v2">%1$d passed, %2$d expected drop, %3$d failed of %4$d/%5$d</string>
<string name="tak_server_test_run">Run</string>
<string name="tak_server_test_running">Running: %1$s</string>
<string name="tak_team">Team Color</string>
@@ -47,6 +47,30 @@ import kotlin.random.Random
import kotlin.time.Clock
import kotlin.time.Duration.Companion.minutes
/**
* Outcome of a single outbound CoT dispatch attempt via [TAKMeshIntegration.sendCoTToMeshV1] /
* [TAKMeshIntegration.sendCoTToMeshV2]. Exposed (internal) so [TakMeshTestRunner] can distinguish an intentional,
* schema-driven drop (e.g. v1's TAKPacket only representing PLI/GeoChat) from a genuine send failure, rather than
* reporting both as the same opaque "failed".
*/
internal sealed interface TakSendOutcome {
/** The CoT was successfully handed to [CommandSender.sendData] as [wireBytes] bytes. */
data class Sent(val wireBytes: Int) : TakSendOutcome
/**
* The CoT was never sent because the active protocol's schema/size limits can't represent it.
*
* @param schemaLimited true only when the drop is because the protocol's CoT type coverage doesn't include this
* fixture's type (e.g. v1's TAKPacket only representing PLI/GeoChat) — a known, permanent limitation of that
* protocol version. False for an oversize drop or any other reason: those are real MTU/size problems that happen
* to hit a payload that *is* representable, and must not be reported as an expected/intentional limitation.
*/
data class Dropped(val reason: String, val schemaLimited: Boolean) : TakSendOutcome
/** The CoT should have been sent but the attempt threw (radio disconnected, queue full, etc). */
data class Failed(val reason: String) : TakSendOutcome
}
/**
* Bidirectional bridge between the local TAK server and the Meshtastic mesh network.
*
@@ -64,6 +88,7 @@ import kotlin.time.Duration.Companion.minutes
* from older nodes in mixed-firmware mesh deployments.
*/
@OptIn(ExperimentalAtomicApi::class)
@Suppress("TooManyFunctions")
class TAKMeshIntegration(
private val takServerManager: TAKServerManager,
private val commandSender: CommandSender,
@@ -173,20 +198,29 @@ class TAKMeshIntegration(
return Capabilities(fw).supportsTakV2
}
private suspend fun sendCoTToMesh(cotMessage: CoTMessage) {
if (useTakV2()) {
sendCoTToMeshV2(cotMessage)
} else {
sendCoTToMeshV1(cotMessage)
}
private suspend fun sendCoTToMesh(cotMessage: CoTMessage): TakSendOutcome = if (useTakV2()) {
sendCoTToMeshV2(cotMessage)
} else {
sendCoTToMeshV1(cotMessage)
}
/**
* Test-only entry point for [TakMeshTestRunner]'s debug self-test: dispatch [cotMessage] through the real
* production v1/v2 pipeline, but with the protocol explicitly forced via [forceV2] instead of read from the
* connected radio's firmware (as [useTakV2] does). This lets the self-test exercise BOTH real dispatch paths
* deterministically in a single run regardless of which firmware happens to be connected — closing the blind spot
* where the self-test always exercised the v2 pipeline even when a real connected radio on firmware < 2.8.0 would
* silently fall back to the much more limited v1 path.
*/
internal suspend fun sendCoTToMeshForTest(cotMessage: CoTMessage, forceV2: Boolean): TakSendOutcome =
if (forceV2) sendCoTToMeshV2(cotMessage) else sendCoTToMeshV1(cotMessage)
/**
* v2 send path (firmware >= 2.8.0): SDK parser + zstd dictionary compression, full typed payload support
* (DrawnShape, Marker, Route, Aircraft, Casevac, Emergency, Task, plus PLI / GeoChat). Wire format: `[flags
* byte][zstd-compressed TAKPacketV2 protobuf]` on port 78 (ATAK_PLUGIN_V2).
*/
private suspend fun sendCoTToMeshV2(cotMessage: CoTMessage) {
private suspend fun sendCoTToMeshV2(cotMessage: CoTMessage): TakSendOutcome {
// Prefer the sourceEventXml for shape/marker/route types — the SDK's
// CotXmlParser extracts compact typed payloads (DrawnShape, Marker,
// Route, etc.) that compress far better than raw_detail encoding.
@@ -236,14 +270,20 @@ class TAKMeshIntegration(
}
}
}
return
return TakSendOutcome.Dropped(
"Oversized (>${MAX_TAK_WIRE_PAYLOAD_BYTES}B)",
schemaLimited = false,
)
}
} catch (e: Exception) {
Logger.w(e) { "SDK parser/compressor failed for ${cotMessage.type}, trying app conversion" }
val takPacketV2 = cotMessage.toTAKPacketV2()
if (takPacketV2 == null) {
Logger.w { "Cannot convert CoT type ${cotMessage.type} to TAKPacketV2, dropping" }
return
return TakSendOutcome.Dropped(
"Cannot convert CoT type ${cotMessage.type} to TAKPacketV2",
schemaLimited = false,
)
}
try {
TakV2Compressor.compress(takPacketV2)
@@ -253,7 +293,7 @@ class TAKMeshIntegration(
}
}
try {
return try {
val dataPacket =
DataPacket(
to = NodeAddress.ID_BROADCAST,
@@ -262,6 +302,7 @@ class TAKMeshIntegration(
)
commandSender.sendData(dataPacket)
Logger.d { "Sent V2 to mesh: ${cotMessage.type} (${wirePayload.size} bytes)" }
TakSendOutcome.Sent(wirePayload.size)
} catch (e: kotlin.coroutines.cancellation.CancellationException) {
throw e
} catch (e: Exception) {
@@ -269,6 +310,7 @@ class TAKMeshIntegration(
Logger.e(e) {
"Failed to send TAKPacketV2 to mesh (${cotMessage.type}, ${wirePayload.size} bytes): ${e.message}"
}
TakSendOutcome.Failed(e.message ?: "Send failed")
}
}
@@ -277,7 +319,7 @@ class TAKMeshIntegration(
* compression. Only PLI and GeoChat payloads are supported by the v1 schema — shapes, markers, routes, casevac,
* emergency, and task CoT events are dropped with a warning.
*/
private suspend fun sendCoTToMeshV1(cotMessage: CoTMessage) {
private suspend fun sendCoTToMeshV1(cotMessage: CoTMessage): TakSendOutcome {
val takPacket =
cotMessage.toTAKPacket()
?: run {
@@ -286,7 +328,10 @@ class TAKMeshIntegration(
"in v1 TAKPacket schema (only PLI and GeoChat are supported). " +
"Upgrade radio firmware to >= 2.8.0 for full payload support."
}
return
return TakSendOutcome.Dropped(
"Not representable in v1 TAKPacket schema (only PLI and GeoChat are supported)",
schemaLimited = true,
)
}
val wirePayload = TAKPacket.ADAPTER.encode(takPacket)
@@ -295,10 +340,10 @@ class TAKMeshIntegration(
"Dropping oversized v1 TAK packet: type=${cotMessage.type} " +
"size=${wirePayload.size}B max=$MAX_TAK_WIRE_PAYLOAD_BYTES"
}
return
return TakSendOutcome.Dropped("Oversized (>${MAX_TAK_WIRE_PAYLOAD_BYTES}B)", schemaLimited = false)
}
try {
return try {
val dataPacket =
DataPacket(
to = NodeAddress.ID_BROADCAST,
@@ -307,12 +352,14 @@ class TAKMeshIntegration(
)
commandSender.sendData(dataPacket)
Logger.d { "Sent V1 to mesh: ${cotMessage.type} (${wirePayload.size} bytes)" }
TakSendOutcome.Sent(wirePayload.size)
} catch (e: kotlin.coroutines.cancellation.CancellationException) {
throw e
} catch (e: Exception) {
Logger.e(e) {
"Failed to send v1 TAKPacket to mesh (${cotMessage.type}, ${wirePayload.size} bytes): ${e.message}"
}
TakSendOutcome.Failed(e.message ?: "Send failed")
}
}
@@ -24,11 +24,15 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.NodeAddress
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.proto.PortNum
/** Which outbound TAK protocol a [TakTestResult] was dispatched through. See [TAKMeshIntegration]. */
enum class TakProtocol {
/** Firmware >= 2.8.0: zstd-compressed TAKPacketV2 on port 78, full typed CoT support. */
V2,
/** Firmware <= 2.7.x: bare-protobuf legacy TAKPacket on port 72, PLI + GeoChat only. */
V1,
}
/** Result of sending a single test fixture through the TAK mesh pipeline. */
data class TakTestResult(
@@ -37,15 +41,26 @@ data class TakTestResult(
val compressedBytes: Int,
val passed: Boolean,
val error: String? = null,
val protocol: TakProtocol = TakProtocol.V2,
// Mirrors TakSendOutcome.Dropped.schemaLimited: true only when this fixture was dropped because the active
// protocol's CoT type coverage doesn't include it (e.g. v1's TAKPacket only representing PLI/GeoChat) — a
// known, permanent limitation of that protocol, so this is expected/intentional behavior, not a self-test
// regression. False for an oversize drop (a real MTU problem, even if it happened on v1) or any failure —
// never set purely because the result's protocol is V1.
val expectedDrop: Boolean = false,
)
/**
* Debug-only test runner that sends the SDK's CoT XML test fixtures through the real TAK mesh pipeline: strip → parse →
* compress → send to mesh radio.
* Debug-only test runner that sends the SDK's CoT XML test fixtures through the REAL TAK mesh pipeline: strip → parse →
* dispatch, via [TAKMeshIntegration.sendCoTToMeshForTest]. Each fixture is run through BOTH the v2 (firmware >= 2.8.0)
* and v1 (legacy) dispatch paths so the self-test's pass/fail rate reflects what a real connected radio would actually
* do on either firmware generation, rather than always exercising the v2 pipeline regardless of the connected radio's
* firmware — see [TAKMeshIntegration.useTakV2].
*
* Paces sends by waiting [sendDelayMs] between each fixture to avoid flooding the radio's TX queue.
* Paces sends by waiting [sendDelayMs] between each successfully-sent fixture to avoid flooding the radio's TX queue.
* Fixtures that are dropped (not sent) incur no delay.
*/
class TakMeshTestRunner(private val commandSender: CommandSender) {
class TakMeshTestRunner(private val takMeshIntegration: TAKMeshIntegration) {
private val _results = MutableStateFlow<List<TakTestResult>>(emptyList())
val results: StateFlow<List<TakTestResult>> = _results.asStateFlow()
@@ -118,8 +133,8 @@ class TakMeshTestRunner(private val commandSender: CommandSender) {
}
/**
* Run all test fixtures sequentially, sending each through the mesh pipeline. Updates [results] and
* [currentFixture] as each fixture is processed.
* Run all test fixtures sequentially through both the v2 and v1 dispatch paths. Updates [results] and
* [currentFixture] as each fixture is processed. [results] accumulates v2 runs first, then v1 runs.
*/
suspend fun runAll() {
// Use tryLock to prevent concurrent test runs: if another coroutine is already
@@ -133,74 +148,95 @@ class TakMeshTestRunner(private val commandSender: CommandSender) {
val allResults = mutableListOf<TakTestResult>()
for (name in FIXTURE_NAMES) {
_currentFixture.value = name
val result = runSingleFixture(name)
allResults.add(result)
_results.value = allResults.toList()
for (protocol in listOf(TakProtocol.V2, TakProtocol.V1)) {
for (name in FIXTURE_NAMES) {
_currentFixture.value = "$name (${protocol.name.lowercase()})"
val result = runSingleFixture(name, protocol)
allResults.add(result)
_results.value = allResults.toList()
if (result.passed) {
// Wait for radio airtime + ACK before next send
delay(SEND_DELAY_MS)
if (result.passed) {
// Wait for radio airtime + ACK before next send. Dropped fixtures never
// reached the radio, so there's nothing to pace.
delay(SEND_DELAY_MS)
}
}
}
_currentFixture.value = null
val passed = allResults.count { it.passed }
val failed = allResults.size - passed
Logger.i { "TAK Mesh Test complete: $passed/${allResults.size} passed, $failed failed" }
val v2 = allResults.filter { it.protocol == TakProtocol.V2 }
val v1 = allResults.filter { it.protocol == TakProtocol.V1 }
val v2Passed = v2.count { it.passed }
val v1Passed = v1.count { it.passed }
val v1ExpectedDrops = v1.count { it.expectedDrop }
Logger.i {
"TAK Mesh Test complete: v2=$v2Passed/${v2.size} passed; " +
"v1=$v1Passed/${v1.size} passed ($v1ExpectedDrops expected drops — " +
"v1's legacy schema only supports PLI/GeoChat)"
}
} finally {
_isRunning.value = false
runMutex.unlock()
}
}
private suspend fun runSingleFixture(name: String): TakTestResult {
private suspend fun runSingleFixture(name: String, protocol: TakProtocol): TakTestResult {
val forceV2 = protocol == TakProtocol.V2
// Load fixture XML from bundled resources via platform-specific loader
val xml =
try {
loadTakFixtureXml(name)
} catch (e: Exception) {
Logger.w(e) { "Failed to load fixture $name" }
return TakTestResult(name, 0, 0, false, "Load failed: ${e.message}")
return TakTestResult(name, 0, 0, false, "Load failed: ${e.message}", protocol)
}
// Apply the same pipeline as TAKMeshIntegration.sendCoTToMesh()
val freshXml = TAKMeshIntegration.ensureMinimumStaleForMesh(xml)
val strippedXml = TAKMeshIntegration.stripNonEssentialElements(freshXml)
// Parse and compress via SDK
val wirePayload: ByteArray
try {
val result = TakSdkCompressor.compressCoT(strippedXml, MAX_TAK_WIRE_PAYLOAD_BYTES)
val compressed = result.wirePayload
if (compressed == null) {
Logger.w { "TAK Test: $name oversized even without remarks (xml=${xml.length}B)" }
return TakTestResult(name, xml.length, 0, false, "Oversized (>${MAX_TAK_WIRE_PAYLOAD_BYTES}B)")
// Parse into the same CoTMessage shape a real inbound TAK-client message would produce
// (sourceEventXml preserved) — see CoTXmlParser.buildCoTMessage.
val cotMessage =
CoTXmlParser(xml).parse().getOrElse { e ->
Logger.w(e) { "TAK Test: $name failed to parse as CoT XML: ${e.message}" }
return TakTestResult(name, xml.length, 0, false, "Parse failed: ${e.message}", protocol)
}
wirePayload = compressed
} catch (e: Exception) {
Logger.w(e) { "TAK Test: $name compression failed: ${e.message}" }
return TakTestResult(name, xml.length, 0, false, "Compress failed: ${e.message}")
}
// Send to mesh
try {
val dataPacket =
DataPacket(
to = NodeAddress.ID_BROADCAST,
bytes = wirePayload.toByteString(),
dataType = PortNum.ATAK_PLUGIN_V2.value,
)
commandSender.sendData(dataPacket)
Logger.i { "TAK Test: $name${wirePayload.size}B (xml=${xml.length}B)" }
return TakTestResult(name, xml.length, wirePayload.size, true)
// Dispatch through the REAL production pipeline (TAKMeshIntegration.sendCoTToMeshV1/V2),
// with the protocol forced rather than read from the connected radio's firmware.
return try {
when (val outcome = takMeshIntegration.sendCoTToMeshForTest(cotMessage, forceV2)) {
is TakSendOutcome.Sent -> {
Logger.i { "TAK Test: $name${outcome.wireBytes}B (xml=${xml.length}B) via $protocol" }
TakTestResult(name, xml.length, outcome.wireBytes, true, protocol = protocol)
}
is TakSendOutcome.Dropped -> {
Logger.w { "TAK Test: $name dropped on $protocol: ${outcome.reason}" }
TakTestResult(
fixtureName = name,
xmlBytes = xml.length,
compressedBytes = 0,
passed = false,
error = outcome.reason,
protocol = protocol,
// Only a schema-coverage drop is an expected/intentional limitation (v1's legacy
// schema legitimately supports only PLI/GeoChat). An oversize drop is a real MTU
// problem regardless of which protocol hit it, and must never be mislabeled as
// expected just because it happened on v1.
expectedDrop = outcome.schemaLimited,
)
}
is TakSendOutcome.Failed -> {
Logger.w { "TAK Test: $name send failed on $protocol: ${outcome.reason}" }
TakTestResult(name, xml.length, 0, false, outcome.reason, protocol)
}
}
} catch (e: kotlin.coroutines.cancellation.CancellationException) {
throw e
} catch (e: Exception) {
Logger.w(e) { "TAK Test: $name send failed: ${e.message}" }
return TakTestResult(name, xml.length, wirePayload.size, false, "Send failed: ${e.message}")
TakTestResult(name, xml.length, 0, false, "Send failed: ${e.message}", protocol)
}
}
}
@@ -48,6 +48,7 @@ import org.meshtastic.proto.TAKPacket
import org.meshtastic.proto.User
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
import kotlin.time.Clock
import kotlin.time.Duration.Companion.minutes
@@ -331,6 +332,42 @@ class TAKMeshIntegrationTest {
assertTrue(h.commandSender.sentPackets.isEmpty())
}
// ── Dropped-outcome discrimination (regression: #6583 self-test blind spot) ────────
@Test
fun `v1 drop of an unsupported CoT type is schema-limited`() = runTest(UnconfinedTestDispatcher()) {
// a-h-G is a shape/marker type the legacy v1 TAKPacket schema has no field for at all —
// this is the "permanent limitation" case, not a size problem.
val h = TestHarness(nodeRepository = FakeNodeRepository(firmwareVersion = "2.7.0.0"))
val marker = CoTMessage(uid = "marker-1", type = "a-h-G", stale = Clock.System.now() + 5.minutes)
val outcome = h.integration.sendCoTToMeshForTest(marker, forceV2 = false)
val dropped = assertNotNull(outcome as? TakSendOutcome.Dropped, "expected a Dropped outcome, got $outcome")
assertTrue(dropped.schemaLimited, "an unsupported CoT type must be reported as schema-limited")
}
@Test
fun `v1 drop of an oversize but schema-representable PLI is NOT schema-limited`() =
runTest(UnconfinedTestDispatcher()) {
// a-f-G (PLI) IS representable in the v1 schema — this must be dropped for size, not
// mislabeled as an expected schema gap. This is the exact blind spot the self-test's
// expectedDrop flag has to avoid: an MTU problem hiding behind "expected" v1 behavior.
val h = TestHarness(nodeRepository = FakeNodeRepository(firmwareVersion = "2.7.0.0"))
val oversizePli =
CoTMessage(
uid = "pli-1",
type = "a-f-G-U-C",
stale = Clock.System.now() + 5.minutes,
contact = CoTContact(callsign = "X".repeat(500)),
)
val outcome = h.integration.sendCoTToMeshForTest(oversizePli, forceV2 = false)
val dropped = assertNotNull(outcome as? TakSendOutcome.Dropped, "expected a Dropped outcome, got $outcome")
assertTrue(!dropped.schemaLimited, "an oversize drop of a representable type must not be schema-limited")
}
// ── GeoChat callsign enrichment ──────────────────────────────────────────
@Test
@@ -0,0 +1,180 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
package org.meshtastic.core.takserver
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.meshtastic.core.di.CoroutineDispatchers
import org.meshtastic.core.repository.MeshConfigHandler
import org.meshtastic.core.repository.RadioSessionContext
import org.meshtastic.core.testing.FakeCommandSender
import org.meshtastic.core.testing.FakeNodeRepository
import org.meshtastic.core.testing.FakeServiceRepository
import org.meshtastic.core.testing.FakeTakPrefs
import org.meshtastic.core.testing.TestDataFactory
import org.meshtastic.proto.Channel
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceUIConfig
import org.meshtastic.proto.LoRaRegionPresetMap
import org.meshtastic.proto.LocalConfig
import org.meshtastic.proto.LocalModuleConfig
import org.meshtastic.proto.ModuleConfig
import org.meshtastic.proto.PortNum
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Regression coverage for the self-test blind spot found while investigating #6583: [TakMeshTestRunner] must exercise
* the SAME real dispatch pipeline ([TAKMeshIntegration.sendCoTToMeshV1] / `sendCoTToMeshV2`) that live TAK traffic
* uses, not a hardcoded v2-only shortcut — and it must do so for BOTH protocol versions regardless of whichever
* firmware a test radio happens to report, since [TAKMeshIntegration.useTakV2] gates real traffic on the connected
* radio's firmware and older firmware silently falls back to the much more limited v1 schema.
*/
class TakMeshTestRunnerTest {
private class FakeMeshConfigHandler : MeshConfigHandler {
override val localConfig = MutableStateFlow(LocalConfig())
override val moduleConfig = MutableStateFlow(LocalModuleConfig())
override fun handleDeviceConfig(config: Config, session: RadioSessionContext) = true
override fun handleModuleConfig(config: ModuleConfig, session: RadioSessionContext) = true
override fun handleChannel(channel: Channel, session: RadioSessionContext) = true
override fun handleDeviceUIConfig(config: DeviceUIConfig, session: RadioSessionContext) = true
override fun handleRegionPresets(map: LoRaRegionPresetMap, session: RadioSessionContext) = true
}
private class Harness(firmwareVersion: String?) {
val serverManager = FakeTAKServerManager()
val commandSender = FakeCommandSender()
val serviceRepository = FakeServiceRepository()
val meshConfigHandler = FakeMeshConfigHandler()
val nodeRepository =
FakeNodeRepository().apply {
setMyNodeInfo(TestDataFactory.createMyNodeInfo(firmwareVersion = firmwareVersion))
}
val takPrefs = FakeTakPrefs()
private val dispatcher = UnconfinedTestDispatcher()
val broadcaster =
MeshToCotBroadcaster(
serverManager,
nodeRepository,
takPrefs,
CoroutineDispatchers(io = dispatcher, main = dispatcher, default = dispatcher),
)
val integration =
TAKMeshIntegration(
takServerManager = serverManager,
commandSender = commandSender,
serviceRepository = serviceRepository,
meshConfigHandler = meshConfigHandler,
nodeRepository = nodeRepository,
meshToCotBroadcaster = broadcaster,
)
val runner = TakMeshTestRunner(integration)
}
// ── runAll() covers both protocols ──────────────────────────────────────
@Test
fun `runAll exercises every fixture on both v1 and v2`() = runTest(UnconfinedTestDispatcher()) {
// Firmware is irrelevant here — the runner forces the protocol per pass rather than
// reading Capabilities.supportsTakV2, which is exactly the bug this test guards against.
val h = Harness(firmwareVersion = null)
h.runner.runAll()
val results = h.runner.results.value
assertEquals(TakMeshTestRunner.FIXTURE_NAMES.size * 2, results.size)
assertEquals(TakMeshTestRunner.FIXTURE_NAMES.size, results.count { it.protocol == TakProtocol.V2 })
assertEquals(TakMeshTestRunner.FIXTURE_NAMES.size, results.count { it.protocol == TakProtocol.V1 })
}
@Test
fun `v2 pass sends on ATAK_PLUGIN_V2 regardless of connected firmware`() = runTest(UnconfinedTestDispatcher()) {
// Connected "radio" reports legacy firmware — if the runner still read useTakV2() instead of
// forcing the protocol, this would incorrectly downgrade the v2 pass to v1.
val h = Harness(firmwareVersion = "2.7.0.0")
h.runner.runAll()
val v2Results = h.runner.results.value.filter { it.protocol == TakProtocol.V2 }
val v2Sent = h.commandSender.sentPackets.count { it.dataType == PortNum.ATAK_PLUGIN_V2.value }
assertEquals(v2Results.count { it.passed }, v2Sent, "every passing v2 result must correspond to a v2 send")
assertTrue(v2Sent > 0, "at least one fixture must round-trip through the real v2 pipeline")
}
@Test
fun `v1 pass sends on legacy ATAK_PLUGIN regardless of connected firmware`() = runTest(UnconfinedTestDispatcher()) {
// Connected "radio" reports v2-capable firmware — if the runner still read useTakV2() instead
// of forcing the protocol, this would incorrectly upgrade the v1 pass to v2, hiding the exact
// blind spot reported in #6583.
val h = Harness(firmwareVersion = "2.8.0.0")
h.runner.runAll()
val v1Results = h.runner.results.value.filter { it.protocol == TakProtocol.V1 }
val v1Sent = h.commandSender.sentPackets.count { it.dataType == PortNum.ATAK_PLUGIN.value }
assertEquals(v1Results.count { it.passed }, v1Sent, "every passing v1 result must correspond to a v1 send")
assertTrue(v1Sent > 0, "PLI/GeoChat fixtures must still round-trip through the real v1 pipeline")
}
@Test
fun `v1 pass reports non-PLI non-chat drops as expected not as failures`() = runTest(UnconfinedTestDispatcher()) {
val h = Harness(firmwareVersion = null)
h.runner.runAll()
val v1Results = h.runner.results.value.filter { it.protocol == TakProtocol.V1 }
// marker_spot.xml is type "b-m-p-s-m" — not representable in the legacy v1 TAKPacket
// schema (only PLI/"a-f-*" and chat/"b-t-f" are). This must show up as an *expected*
// drop, not an unlabeled self-test failure, per the v1 schema's documented limitations.
val markerResult = v1Results.single { it.fixtureName == "marker_spot.xml" }
assertTrue(markerResult.expectedDrop, "v1 dropping an unsupported CoT type is expected, not a bug")
assertTrue(!markerResult.passed)
// Every v1 drop must be accounted for: either the known schema-coverage limitation
// (expectedDrop) or an oversize drop, which is a real MTU problem and correctly NOT
// labeled expected even though it happened on v1 — see TakSendOutcome.Dropped.schemaLimited.
// A drop that is neither would be a genuine, unaccounted-for failure.
assertTrue(
v1Results.filter { !it.passed }.all { it.expectedDrop || it.error?.startsWith("Oversized") == true },
"every v1 drop in a clean run should be the known schema limitation or an oversize drop, " +
"not an unaccounted-for failure",
)
}
@Test
fun `v2 pass has no expected drops - v2 must support every fixture type`() = runTest(UnconfinedTestDispatcher()) {
val h = Harness(firmwareVersion = null)
h.runner.runAll()
val v2Results = h.runner.results.value.filter { it.protocol == TakProtocol.V2 }
assertTrue(
v2Results.none { it.expectedDrop },
"v2 has no known schema gaps, so nothing should be labeled expected",
)
}
}
@@ -47,13 +47,13 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject
import org.meshtastic.core.common.BuildConfigProvider
import org.meshtastic.core.model.getColorFrom
import org.meshtastic.core.model.getStringResFrom
import org.meshtastic.core.repository.CommandSender
import org.meshtastic.core.repository.TakPrefs
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.back
@@ -79,15 +79,20 @@ import org.meshtastic.core.resources.tak_server_status_unavailable
import org.meshtastic.core.resources.tak_server_status_waiting
import org.meshtastic.core.resources.tak_server_test_card_title
import org.meshtastic.core.resources.tak_server_test_idle
import org.meshtastic.core.resources.tak_server_test_protocol_v1_label
import org.meshtastic.core.resources.tak_server_test_protocol_v2_label
import org.meshtastic.core.resources.tak_server_test_result_bytes
import org.meshtastic.core.resources.tak_server_test_result_expected_drop
import org.meshtastic.core.resources.tak_server_test_result_unknown_error
import org.meshtastic.core.resources.tak_server_test_results
import org.meshtastic.core.resources.tak_server_test_results_v2
import org.meshtastic.core.resources.tak_server_test_run
import org.meshtastic.core.resources.tak_server_test_running
import org.meshtastic.core.resources.tak_team
import org.meshtastic.core.takserver.TAKDataPackageGenerator
import org.meshtastic.core.takserver.TAKMeshIntegration
import org.meshtastic.core.takserver.TAKServerManager
import org.meshtastic.core.takserver.TakMeshTestRunner
import org.meshtastic.core.takserver.TakProtocol
import org.meshtastic.core.takserver.TakTestResult
import org.meshtastic.core.ui.component.DropDownPreference
import org.meshtastic.core.ui.component.SwitchPreference
@@ -392,8 +397,8 @@ private fun TakMeshTestCard() {
val buildConfig: BuildConfigProvider = koinInject()
if (!buildConfig.isDebug) return
val commandSender: CommandSender = koinInject()
val testRunner = remember { TakMeshTestRunner(commandSender) }
val takMeshIntegration: TAKMeshIntegration = koinInject()
val testRunner = remember { TakMeshTestRunner(takMeshIntegration) }
val results by testRunner.results.collectAsStateWithLifecycle()
val isRunning by testRunner.isRunning.collectAsStateWithLifecycle()
val currentFixture by testRunner.currentFixture.collectAsStateWithLifecycle()
@@ -418,8 +423,8 @@ internal fun TakMeshTestCardContent(
onRunTests: () -> Unit,
) {
val loadingLabel = stringResource(Res.string.tak_server_loading)
val passed = results.count { it.passed }
val failed = results.count { !it.passed }
val v2Results = results.filter { it.protocol == TakProtocol.V2 }
val v1Results = results.filter { it.protocol == TakProtocol.V1 }
TitledCard(title = stringResource(Res.string.tak_server_test_card_title)) {
Row(
@@ -437,25 +442,6 @@ internal fun TakMeshTestCardContent(
},
style = MaterialTheme.typography.bodyLarge,
)
if (results.isNotEmpty()) {
Text(
text =
stringResource(
Res.string.tak_server_test_results,
passed,
failed,
results.size,
fixtureCount,
),
style = MaterialTheme.typography.bodySmall,
color =
if (failed > 0) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
if (isRunning) {
CircularProgressIndicator()
@@ -467,7 +453,58 @@ internal fun TakMeshTestCardContent(
}
}
// Results list
if (v2Results.isNotEmpty()) {
TakProtocolResultsSection(
titleRes = Res.string.tak_server_test_protocol_v2_label,
results = v2Results,
fixtureCount = fixtureCount,
)
}
if (v1Results.isNotEmpty()) {
TakProtocolResultsSection(
titleRes = Res.string.tak_server_test_protocol_v1_label,
results = v1Results,
fixtureCount = fixtureCount,
)
}
}
}
/**
* One protocol's fixture run within the TAK self-test card: a summary line (passed / expected-limitation drops /
* unexpected failures) followed by the per-fixture rows. Expected v1 schema drops are surfaced distinctly from real
* failures so a low v1 pass rate reads as intentional legacy-schema behavior, not a self-test regression.
*/
@Composable
private fun TakProtocolResultsSection(titleRes: StringResource, results: List<TakTestResult>, fixtureCount: Int) {
val passed = results.count { it.passed }
val expectedDrops = results.count { !it.passed && it.expectedDrop }
val unexpectedFailed = results.count { !it.passed && !it.expectedDrop }
Column {
HorizontalDivider()
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
Text(text = stringResource(titleRes), style = MaterialTheme.typography.titleSmall)
Text(
text =
stringResource(
Res.string.tak_server_test_results_v2,
passed,
expectedDrops,
unexpectedFailed,
results.size,
fixtureCount,
),
style = MaterialTheme.typography.bodySmall,
color =
if (unexpectedFailed > 0) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
for (result in results) {
HorizontalDivider()
Row(
@@ -482,13 +519,21 @@ internal fun TakMeshTestCardContent(
)
Text(
text =
if (result.passed) {
stringResource(Res.string.tak_server_test_result_bytes, result.compressedBytes)
} else {
result.error ?: stringResource(Res.string.tak_server_test_result_unknown_error)
when {
result.passed ->
stringResource(Res.string.tak_server_test_result_bytes, result.compressedBytes)
result.expectedDrop -> stringResource(Res.string.tak_server_test_result_expected_drop)
else -> result.error ?: stringResource(Res.string.tak_server_test_result_unknown_error)
},
style = MaterialTheme.typography.bodySmall,
color = if (result.passed) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
color =
when {
result.passed -> MaterialTheme.colorScheme.primary
result.expectedDrop -> MaterialTheme.colorScheme.onSurfaceVariant
else -> MaterialTheme.colorScheme.error
},
)
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 36 KiB