feat: upcoming support for tak and trafficmanagement configs, device hw (#4671)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-02-27 11:44:19 -06:00 committed by GitHub
parent a07992530c
commit b2b21e10e2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 737 additions and 891 deletions

View file

@ -25,6 +25,13 @@ plugins {
apply(from = rootProject.file("gradle/publishing.gradle.kts"))
kotlin {
@Suppress("UnstableApiUsage")
android {
androidResources.enable = false
withHostTest { isIncludeAndroidResources = true }
withDeviceTest { instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" }
}
sourceSets {
commonMain.dependencies {
api(projects.core.proto)
@ -37,9 +44,25 @@ kotlin {
}
androidMain.dependencies {
api(libs.androidx.annotation)
api(libs.androidx.core.ktx)
implementation(libs.zxing.core)
}
commonTest.dependencies { implementation(kotlin("test")) }
val androidHostTest by getting {
dependencies {
implementation(libs.junit)
implementation(libs.robolectric)
implementation(libs.mockk)
implementation(libs.androidx.test.ext.junit)
implementation(kotlin("test"))
}
}
val androidDeviceTest by getting {
dependencies {
implementation(libs.androidx.test.ext.junit)
implementation(libs.androidx.test.runner)
}
}
}
}

View file

@ -16,6 +16,7 @@
*/
package org.meshtastic.core.model
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
@ -25,60 +26,74 @@ class CapabilitiesTest {
private fun caps(version: String?) = Capabilities(version, forceEnableAll = false)
@Test
fun `canMuteNode requires v2 7 18`() {
fun canMuteNodeRequiresV2718() {
assertFalse(caps("2.7.15").canMuteNode)
assertTrue(caps("2.7.18").canMuteNode)
assertTrue(caps("2.8.0").canMuteNode)
assertTrue(caps("2.8.1").canMuteNode)
}
// FIXME: needs updating when NeighborInfo is working properly
@Test
fun `canRequestNeighborInfo disabled`() {
fun canRequestNeighborInfoIsCurrentlyDisabled() {
assertFalse(caps("2.7.14").canRequestNeighborInfo)
assertFalse(caps("2.7.15").canRequestNeighborInfo)
assertFalse(caps("2.8.0").canRequestNeighborInfo)
assertFalse(caps("3.0.0").canRequestNeighborInfo)
}
@Test
fun `canSendVerifiedContacts requires v2 7 12`() {
fun canSendVerifiedContactsRequiresV2712() {
assertFalse(caps("2.7.11").canSendVerifiedContacts)
assertTrue(caps("2.7.12").canSendVerifiedContacts)
assertTrue(caps("2.7.15").canSendVerifiedContacts)
}
@Test
fun `canToggleTelemetryEnabled requires v2 7 12`() {
fun canToggleTelemetryEnabledRequiresV2712() {
assertFalse(caps("2.7.11").canToggleTelemetryEnabled)
assertTrue(caps("2.7.12").canToggleTelemetryEnabled)
}
@Test
fun `canToggleUnmessageable requires v2 6 9`() {
fun canToggleUnmessageableRequiresV269() {
assertFalse(caps("2.6.8").canToggleUnmessageable)
assertTrue(caps("2.6.9").canToggleUnmessageable)
}
@Test
fun `supportsQrCodeSharing requires v2 6 8`() {
fun supportsQrCodeSharingRequiresV268() {
assertFalse(caps("2.6.7").supportsQrCodeSharing)
assertTrue(caps("2.6.8").supportsQrCodeSharing)
}
@Test
fun `supportsSecondaryChannelLocation requires v2 6 10`() {
fun supportsSecondaryChannelLocationRequiresV2610() {
assertFalse(caps("2.6.9").supportsSecondaryChannelLocation)
assertTrue(caps("2.6.10").supportsSecondaryChannelLocation)
}
@Test
fun `supportsStatusMessage requires v2 7 17`() {
fun supportsStatusMessageRequiresV2717() {
assertFalse(caps("2.7.16").supportsStatusMessage)
assertTrue(caps("2.7.17").supportsStatusMessage)
}
@Test
fun `null firmware returns all false`() {
fun supportsTrafficManagementConfigRequiresV300() {
assertFalse(caps("2.7.18").supportsTrafficManagementConfig)
assertTrue(caps("3.0.0").supportsTrafficManagementConfig)
}
@Test
fun supportsTakConfigRequiresV2719() {
assertFalse(caps("2.7.18").supportsTakConfig)
assertTrue(caps("2.7.19").supportsTakConfig)
}
@Test
fun supportsEsp32OtaRequiresV2718() {
assertFalse(caps("2.7.17").supportsEsp32Ota)
assertTrue(caps("2.7.18").supportsEsp32Ota)
}
@Test
fun nullFirmwareReturnsAllFalse() {
val c = caps(null)
assertFalse(c.canMuteNode)
assertFalse(c.canRequestNeighborInfo)
@ -88,44 +103,35 @@ class CapabilitiesTest {
assertFalse(c.supportsQrCodeSharing)
assertFalse(c.supportsSecondaryChannelLocation)
assertFalse(c.supportsStatusMessage)
assertFalse(c.supportsTrafficManagementConfig)
assertFalse(c.supportsTakConfig)
assertFalse(c.supportsEsp32Ota)
}
@Test
fun `invalid firmware returns all false`() {
val c = caps("invalid")
assertFalse(c.canMuteNode)
assertFalse(c.canRequestNeighborInfo)
assertFalse(c.canSendVerifiedContacts)
assertFalse(c.canToggleTelemetryEnabled)
assertFalse(c.canToggleUnmessageable)
assertFalse(c.supportsQrCodeSharing)
assertFalse(c.supportsSecondaryChannelLocation)
assertFalse(c.supportsStatusMessage)
}
@Test
fun `forceEnableAll returns true for everything regardless of version`() {
fun forceEnableAllReturnsTrueForEverythingRegardlessOfVersion() {
val c = Capabilities(firmwareVersion = null, forceEnableAll = true)
assertTrue(c.canMuteNode)
assertTrue(c.canRequestNeighborInfo)
assertTrue(c.canSendVerifiedContacts)
assertTrue(c.canToggleTelemetryEnabled)
assertTrue(c.canToggleUnmessageable)
assertTrue(c.supportsQrCodeSharing)
assertTrue(c.supportsSecondaryChannelLocation)
assertTrue(c.supportsStatusMessage)
assertTrue(c.supportsTrafficManagementConfig)
assertTrue(c.supportsTakConfig)
}
@Test
fun `forceEnableAll returns true even for invalid versions`() {
val c = Capabilities(firmwareVersion = "invalid", forceEnableAll = true)
assertTrue(c.canMuteNode)
assertTrue(c.canRequestNeighborInfo)
assertTrue(c.canSendVerifiedContacts)
assertTrue(c.canToggleTelemetryEnabled)
assertTrue(c.canToggleUnmessageable)
assertTrue(c.supportsQrCodeSharing)
assertTrue(c.supportsSecondaryChannelLocation)
assertTrue(c.supportsStatusMessage)
fun deviceVersionParsingIsRobust() {
assertEquals(20712, DeviceVersion("2.7.12").asInt)
assertEquals(20712, DeviceVersion("2.7.12-beta").asInt)
assertEquals(30000, DeviceVersion("3.0.0").asInt)
assertEquals(20700, DeviceVersion("2.7").asInt) // Handles 2-part versions
assertEquals(0, DeviceVersion("invalid").asInt)
}
@Test
fun deviceVersionComparisonIsCorrect() {
assertTrue(DeviceVersion("2.7.12") >= DeviceVersion("2.7.11"))
assertTrue(DeviceVersion("3.0.0") > DeviceVersion("2.8.1"))
assertTrue(DeviceVersion("2.7.12") == DeviceVersion("2.7.12"))
assertFalse(DeviceVersion("2.6.9") >= DeviceVersion("2.7.0"))
}
}

View file

@ -119,24 +119,24 @@ class DataPacketParcelTest {
)
private fun assertDataPacketsEqual(expected: DataPacket, actual: DataPacket) {
assertEquals("to", expected.to, actual.to)
assertEquals("bytes", expected.bytes, actual.bytes)
assertEquals("dataType", expected.dataType, actual.dataType)
assertEquals("from", expected.from, actual.from)
assertEquals("time", expected.time, actual.time)
assertEquals("id", expected.id, actual.id)
assertEquals("status", expected.status, actual.status)
assertEquals("hopLimit", expected.hopLimit, actual.hopLimit)
assertEquals("channel", expected.channel, actual.channel)
assertEquals("wantAck", expected.wantAck, actual.wantAck)
assertEquals("hopStart", expected.hopStart, actual.hopStart)
assertEquals("snr", expected.snr, actual.snr, 0.001f)
assertEquals("rssi", expected.rssi, actual.rssi)
assertEquals("replyId", expected.replyId, actual.replyId)
assertEquals("relayNode", expected.relayNode, actual.relayNode)
assertEquals("relays", expected.relays, actual.relays)
assertEquals("viaMqtt", expected.viaMqtt, actual.viaMqtt)
assertEquals("emoji", expected.emoji, actual.emoji)
assertEquals("sfppHash", expected.sfppHash, actual.sfppHash)
assertEquals(expected.to, actual.to)
assertEquals(expected.bytes, actual.bytes)
assertEquals(expected.dataType, actual.dataType)
assertEquals(expected.from, actual.from)
assertEquals(expected.time, actual.time)
assertEquals(expected.id, actual.id)
assertEquals(expected.status, actual.status)
assertEquals(expected.hopLimit, actual.hopLimit)
assertEquals(expected.channel, actual.channel)
assertEquals(expected.wantAck, actual.wantAck)
assertEquals(expected.hopStart, actual.hopStart)
assertEquals(expected.snr, actual.snr, 0.001f)
assertEquals(expected.rssi, actual.rssi)
assertEquals(expected.replyId, actual.replyId)
assertEquals(expected.relayNode, actual.relayNode)
assertEquals(expected.relays, actual.relays)
assertEquals(expected.viaMqtt, actual.viaMqtt)
assertEquals(expected.emoji, actual.emoji)
assertEquals(expected.sfppHash, actual.sfppHash)
}
}

View file

@ -21,6 +21,7 @@ import kotlinx.serialization.json.Json
import okio.ByteString.Companion.toByteString
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@ -36,7 +37,7 @@ class DataPacketTest {
assertEquals(hash, packet.sfppHash)
val packetNoHash = DataPacket(to = "to", channel = 0, text = "hello")
assertEquals(null, packetNoHash.sfppHash)
assertNull(packetNoHash.sfppHash)
}
@Test

View file

@ -18,7 +18,7 @@ package org.meshtastic.core.model
import androidx.core.os.LocaleListCompat
import org.junit.After
import org.junit.Assert
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.meshtastic.proto.Config
@ -50,16 +50,16 @@ class NodeInfoTest {
@Test
fun distanceGood() {
Assert.assertEquals(node[1].distance(node[2]), 1111)
Assert.assertEquals(node[1].distance(node[3]), 111)
Assert.assertEquals(node[1].distance(node[4]), 1779)
assertEquals(1111, node[1].distance(node[2]))
assertEquals(111, node[1].distance(node[3]))
assertEquals(1779, node[1].distance(node[4]))
}
@Test
fun distanceStrGood() {
Assert.assertEquals(node[1].distanceStr(node[2], Config.DisplayConfig.DisplayUnits.METRIC.value), "1.1 km")
Assert.assertEquals(node[1].distanceStr(node[3], Config.DisplayConfig.DisplayUnits.METRIC.value), "111 m")
Assert.assertEquals(node[1].distanceStr(node[4], Config.DisplayConfig.DisplayUnits.IMPERIAL.value), "1.1 mi")
Assert.assertEquals(node[1].distanceStr(node[3], Config.DisplayConfig.DisplayUnits.IMPERIAL.value), "364 ft")
assertEquals("1.1 km", node[1].distanceStr(node[2], Config.DisplayConfig.DisplayUnits.METRIC.value))
assertEquals("111 m", node[1].distanceStr(node[3], Config.DisplayConfig.DisplayUnits.METRIC.value))
assertEquals("1.1 mi", node[1].distanceStr(node[4], Config.DisplayConfig.DisplayUnits.IMPERIAL.value))
assertEquals("364 ft", node[1].distanceStr(node[3], Config.DisplayConfig.DisplayUnits.IMPERIAL.value))
}
}

View file

@ -16,22 +16,23 @@
*/
package org.meshtastic.core.model
import org.junit.Assert
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class PositionTest {
@Test
fun degGood() {
Assert.assertEquals(Position.degI(89.0), 890000000)
Assert.assertEquals(Position.degI(-89.0), -890000000)
assertEquals(Position.degI(89.0), 890000000)
assertEquals(Position.degI(-89.0), -890000000)
Assert.assertEquals(Position.degD(Position.degI(89.0)), 89.0, 0.01)
Assert.assertEquals(Position.degD(Position.degI(-89.0)), -89.0, 0.01)
assertEquals(89.0, Position.degD(Position.degI(89.0)), 0.01)
assertEquals(-89.0, Position.degD(Position.degI(-89.0)), 0.01)
}
@Test
fun givenPositionCreatedWithoutTime_thenTimeIsSet() {
val position = Position(37.1, 121.1, 35)
Assert.assertTrue(position.time != 0)
assertTrue(position.time != 0)
}
}

View file

@ -58,7 +58,7 @@ class SharedContactTest {
assertEquals("Suzume", contact.user?.long_name)
}
@Test(expected = java.net.MalformedURLException::class)
@Test(expected = MalformedMeshtasticUrlException::class)
fun testInvalidHostThrows() {
val original = SharedContact(user = User(long_name = "Suzume"), node_num = 12345)
val urlStr = original.getSharedContactUrl().toString().replace("meshtastic.org", "example.com")
@ -66,7 +66,7 @@ class SharedContactTest {
url.toSharedContact()
}
@Test(expected = java.net.MalformedURLException::class)
@Test(expected = MalformedMeshtasticUrlException::class)
fun testInvalidPathThrows() {
val original = SharedContact(user = User(long_name = "Suzume"), node_num = 12345)
val urlStr = original.getSharedContactUrl().toString().replace("/v/", "/wrong/")
@ -74,21 +74,21 @@ class SharedContactTest {
url.toSharedContact()
}
@Test(expected = java.net.MalformedURLException::class)
@Test(expected = MalformedMeshtasticUrlException::class)
fun testMissingFragmentThrows() {
val urlStr = "https://meshtastic.org/v/"
val url = Uri.parse(urlStr)
url.toSharedContact()
}
@Test(expected = java.net.MalformedURLException::class)
@Test(expected = MalformedMeshtasticUrlException::class)
fun testInvalidBase64Throws() {
val urlStr = "https://meshtastic.org/v/#InvalidBase64!!!!"
val url = Uri.parse(urlStr)
url.toSharedContact()
}
@Test(expected = java.net.MalformedURLException::class)
@Test(expected = MalformedMeshtasticUrlException::class)
fun testInvalidProtoThrows() {
// Tag 0 is invalid in Protobuf
// 0x00 -> Tag 0, Type 0.

View file

@ -32,7 +32,7 @@ class UriUtilsTest {
@Test
fun `handleMeshtasticUri handles channel share uri`() {
val uri = Uri.parse("https://meshtastic.org/e/somechannel")
val uri = Uri.parse("https://meshtastic.org/e/somechannel").toCommonUri()
var channelCalled = false
val handled = handleMeshtasticUri(uri, onChannel = { channelCalled = true })
assertTrue("Should handle channel URI", handled)
@ -41,7 +41,7 @@ class UriUtilsTest {
@Test
fun `handleMeshtasticUri handles contact share uri`() {
val uri = Uri.parse("https://meshtastic.org/v/somecontact")
val uri = Uri.parse("https://meshtastic.org/v/somecontact").toCommonUri()
var contactCalled = false
val handled = handleMeshtasticUri(uri, onContact = { contactCalled = true })
assertTrue("Should handle contact URI", handled)
@ -50,21 +50,21 @@ class UriUtilsTest {
@Test
fun `handleMeshtasticUri ignores other hosts`() {
val uri = Uri.parse("https://example.com/e/somechannel")
val uri = Uri.parse("https://example.com/e/somechannel").toCommonUri()
val handled = handleMeshtasticUri(uri)
assertFalse("Should not handle other hosts", handled)
}
@Test
fun `handleMeshtasticUri ignores other paths`() {
val uri = Uri.parse("https://meshtastic.org/other/path")
val uri = Uri.parse("https://meshtastic.org/other/path").toCommonUri()
val handled = handleMeshtasticUri(uri)
assertFalse("Should not handle unknown paths", handled)
}
@Test
fun `handleMeshtasticUri handles case insensitivity`() {
val uri = Uri.parse("https://MESHTASTIC.ORG/E/somechannel")
val uri = Uri.parse("https://MESHTASTIC.ORG/E/somechannel").toCommonUri()
var channelCalled = false
val handled = handleMeshtasticUri(uri, onChannel = { channelCalled = true })
assertTrue("Should handle mixed case URI", handled)
@ -73,7 +73,7 @@ class UriUtilsTest {
@Test
fun `handleMeshtasticUri handles www host`() {
val uri = Uri.parse("https://www.meshtastic.org/e/somechannel")
val uri = Uri.parse("https://www.meshtastic.org/e/somechannel").toCommonUri()
var channelCalled = false
val handled = handleMeshtasticUri(uri, onChannel = { channelCalled = true })
assertTrue("Should handle www host", handled)
@ -82,7 +82,7 @@ class UriUtilsTest {
@Test
fun `handleMeshtasticUri handles long channel path`() {
val uri = Uri.parse("https://meshtastic.org/channel/e/somechannel")
val uri = Uri.parse("https://meshtastic.org/channel/e/somechannel").toCommonUri()
var channelCalled = false
val handled = handleMeshtasticUri(uri, onChannel = { channelCalled = true })
assertTrue("Should handle long channel path", handled)
@ -91,7 +91,7 @@ class UriUtilsTest {
@Test
fun `handleMeshtasticUri handles long contact path`() {
val uri = Uri.parse("https://meshtastic.org/contact/v/somecontact")
val uri = Uri.parse("https://meshtastic.org/contact/v/somecontact").toCommonUri()
var contactCalled = false
val handled = handleMeshtasticUri(uri, onContact = { contactCalled = true })
assertTrue("Should handle long contact path", handled)

View file

@ -1,100 +0,0 @@
/*
* Copyright (c) 2025-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.model.util
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.meshtastic.proto.EnvironmentMetrics
import org.meshtastic.proto.MeshPacket
import org.meshtastic.proto.Telemetry
class ExtensionsTest {
@Test
fun `isDirectSignal returns true for valid LoRa non-MQTT packets with matching hops`() {
val packet =
MeshPacket(
rx_time = 123456,
hop_start = 3,
hop_limit = 3,
via_mqtt = false,
transport_mechanism = MeshPacket.TransportMechanism.TRANSPORT_LORA,
)
assertTrue(packet.isDirectSignal())
}
@Test
fun `isDirectSignal returns false if via MQTT`() {
val packet =
MeshPacket(
rx_time = 123456,
hop_start = 3,
hop_limit = 3,
via_mqtt = true,
transport_mechanism = MeshPacket.TransportMechanism.TRANSPORT_LORA,
)
assertFalse(packet.isDirectSignal())
}
@Test
fun `isDirectSignal returns false if hops do not match`() {
val packet =
MeshPacket(
rx_time = 123456,
hop_start = 3,
hop_limit = 2,
via_mqtt = false,
transport_mechanism = MeshPacket.TransportMechanism.TRANSPORT_LORA,
)
assertFalse(packet.isDirectSignal())
}
@Test
fun `isDirectSignal returns false if rx_time is zero`() {
val packet =
MeshPacket(
rx_time = 0,
hop_start = 3,
hop_limit = 3,
via_mqtt = false,
transport_mechanism = MeshPacket.TransportMechanism.TRANSPORT_LORA,
)
assertFalse(packet.isDirectSignal())
}
@Test
fun `hasValidEnvironmentMetrics returns true when temperature and humidity are present and valid`() {
val telemetry =
Telemetry(environment_metrics = EnvironmentMetrics(temperature = 25.0f, relative_humidity = 50.0f))
assertTrue(telemetry.hasValidEnvironmentMetrics())
}
@Test
fun `hasValidEnvironmentMetrics returns false if temperature is NaN`() {
val telemetry =
Telemetry(environment_metrics = EnvironmentMetrics(temperature = Float.NaN, relative_humidity = 50.0f))
assertFalse(telemetry.hasValidEnvironmentMetrics())
}
@Test
fun `hasValidEnvironmentMetrics returns false if humidity is missing`() {
val telemetry =
Telemetry(environment_metrics = EnvironmentMetrics(temperature = 25.0f, relative_humidity = null))
assertFalse(telemetry.hasValidEnvironmentMetrics())
}
}

View file

@ -1,95 +0,0 @@
/*
* Copyright (c) 2025-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.model.util
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Test
class SfppHasherTest {
@Test
fun `computeMessageHash produces consistent results`() {
val payload = "Hello World".toByteArray()
val to = 1234
val from = 5678
val id = 999
val hash1 = SfppHasher.computeMessageHash(payload, to, from, id)
val hash2 = SfppHasher.computeMessageHash(payload, to, from, id)
assertArrayEquals(hash1, hash2)
assertEquals(16, hash1.size)
}
@Test
fun `computeMessageHash produces different results for different inputs`() {
val payload = "Hello World".toByteArray()
val to = 1234
val from = 5678
val id = 999
val hashBase = SfppHasher.computeMessageHash(payload, to, from, id)
// Different payload
val hashDiffPayload = SfppHasher.computeMessageHash("Hello Work".toByteArray(), to, from, id)
assertNotEquals(hashBase.toList(), hashDiffPayload.toList())
// Different to
val hashDiffTo = SfppHasher.computeMessageHash(payload, 1235, from, id)
assertNotEquals(hashBase.toList(), hashDiffTo.toList())
// Different from
val hashDiffFrom = SfppHasher.computeMessageHash(payload, to, 5679, id)
assertNotEquals(hashBase.toList(), hashDiffFrom.toList())
// Different id
val hashDiffId = SfppHasher.computeMessageHash(payload, to, from, 1000)
assertNotEquals(hashBase.toList(), hashDiffId.toList())
}
@Test
fun `computeMessageHash handles large values`() {
val payload = byteArrayOf(1, 2, 3)
// Testing that large unsigned-like values don't cause issues
val to = -1 // 0xFFFFFFFF
val from = 0x7FFFFFFF
val id = Int.MIN_VALUE
val hash = SfppHasher.computeMessageHash(payload, to, from, id)
assertEquals(16, hash.size)
}
@Test
fun `computeMessageHash follows little endian for integers`() {
// This test ensures that the hash is computed consistently with the firmware
// which uses little-endian byte order for these fields.
val payload = byteArrayOf()
val to = 0x01020304
val from = 0x05060708
val id = 0x090A0B0C
val hash = SfppHasher.computeMessageHash(payload, to, from, id)
assertNotNull(hash)
assertEquals(16, hash.size)
}
private fun assertNotNull(any: Any?) {
if (any == null) throw AssertionError("Should not be null")
}
}

View file

@ -1,103 +0,0 @@
/*
* Copyright (c) 2025-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.model.util
import kotlinx.datetime.TimeZone
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.meshtastic.core.common.util.await
import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.nowSeconds
import org.meshtastic.core.common.util.secondsToInstant
import org.meshtastic.core.common.util.toDate
import org.meshtastic.core.common.util.toInstant
import java.util.concurrent.CountDownLatch
import kotlin.time.Clock
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlin.time.Instant
class TimeExtensionsTest {
@Test
fun testNowMillis() {
val start = Clock.System.now().toEpochMilliseconds()
val now = nowMillis
val end = Clock.System.now().toEpochMilliseconds()
assertTrue(now in start..end)
}
@Test
fun testNowSeconds() {
val start = Clock.System.now().epochSeconds
val now = nowSeconds
val end = Clock.System.now().epochSeconds
assertTrue(now in start..end)
}
@Test
fun testToDate() {
val instant = Instant.fromEpochMilliseconds(1234567890L)
val date = instant.toDate()
assertEquals(1234567890L, date.time)
}
@Test
fun testLongToInstant() {
val millis = 1234567890L
val instant = millis.toInstant()
assertEquals(millis, instant.toEpochMilliseconds())
}
@Test
fun testIntSecondsToInstant() {
val seconds = 1234567890
val instant = seconds.secondsToInstant()
assertEquals(seconds.toLong(), instant.epochSeconds)
}
@Test
fun testDurationInWholeSeconds() {
assertEquals(60L, 60.seconds.inWholeSeconds)
assertEquals(3600L, TimeConstants.ONE_HOUR.inWholeSeconds)
}
@Test
fun testLongSecondsProperty() {
assertEquals(60.seconds, 60L.seconds)
}
@Test
fun testCountDownLatchAwaitWithDuration() {
val latch = CountDownLatch(1)
// This should timeout quickly
val result = latch.await(10.milliseconds)
assertEquals(false, result)
val latch2 = CountDownLatch(1)
latch2.countDown()
val result2 = latch2.await(1.seconds)
assertEquals(true, result2)
}
@Test
fun testTimeZoneToPosixString() {
val tz = TimeZone.of("UTC")
assertEquals("UTC0", tz.toPosixString())
}
}

View file

@ -1,118 +0,0 @@
/*
* Copyright (c) 2025-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.model.util
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.meshtastic.core.model.util.UnitConversions.toTempString
class UnitConversionsTest {
// Test data: (celsius, isFahrenheit, expected)
private val tempTestCases =
listOf(
// Issue #4150: negative zero should display as "0"
Triple(-0.1f, false, "0°C"),
Triple(-0.2f, false, "0°C"),
Triple(-0.4f, false, "0°C"),
Triple(-0.49f, false, "0°C"),
// Boundary: -0.5 rounds to -1
Triple(-0.5f, false, "-1°C"),
Triple(-0.9f, false, "-1°C"),
Triple(-1.0f, false, "-1°C"),
// Zero and small positives
Triple(0.0f, false, "0°C"),
Triple(0.1f, false, "0°C"),
Triple(0.4f, false, "0°C"),
// Typical values
Triple(1.0f, false, "1°C"),
Triple(20.0f, false, "20°C"),
Triple(25.4f, false, "25°C"),
Triple(25.5f, false, "26°C"),
// Negative
Triple(-5.0f, false, "-5°C"),
Triple(-10.0f, false, "-10°C"),
Triple(-20.4f, false, "-20°C"),
// Fahrenheit conversions
Triple(0.0f, true, "32°F"),
Triple(20.0f, true, "68°F"),
Triple(25.0f, true, "77°F"),
Triple(100.0f, true, "212°F"),
Triple(-40.0f, true, "-40°F"), // -40°C = -40°F
// Issue #4150: negative zero in Fahrenheit
Triple(-0.1f, true, "32°F"),
Triple(-17.78f, true, "0°F"),
)
@Test
fun `toTempString formats all temperatures correctly`() {
tempTestCases.forEach { (celsius, isFahrenheit, expected) ->
assertEquals(
"Failed for $celsius°C (Fahrenheit=$isFahrenheit)",
expected,
celsius.toTempString(isFahrenheit),
)
}
}
@Test
fun `toTempString handles extreme temperatures`() {
assertEquals("100°C", 100.0f.toTempString(false))
assertEquals("-40°C", (-40.0f).toTempString(false))
assertEquals("-40°F", (-40.0f).toTempString(true))
}
@Test
fun `toTempString handles NaN`() {
assertEquals("--", Float.NaN.toTempString(false))
assertEquals("--", Float.NaN.toTempString(true))
}
@Test
fun `celsiusToFahrenheit converts correctly`() {
mapOf(
0.0f to 32.0f,
20.0f to 68.0f,
100.0f to 212.0f,
-40.0f to -40.0f,
).forEach { (celsius, expectedFahrenheit) ->
assertEquals(expectedFahrenheit, UnitConversions.celsiusToFahrenheit(celsius), 0.01f)
}
}
@Test
fun `calculateDewPoint returns expected values`() {
// At 100% humidity, dew point equals temperature
assertEquals(20.0f, UnitConversions.calculateDewPoint(20.0f, 100.0f), 0.1f)
// Known reference: 20°C at 60% humidity ≈ 12°C dew point
assertEquals(12.0f, UnitConversions.calculateDewPoint(20.0f, 60.0f), 0.5f)
// Higher humidity = higher dew point
val highHumidity = UnitConversions.calculateDewPoint(25.0f, 80.0f)
val lowHumidity = UnitConversions.calculateDewPoint(25.0f, 40.0f)
assertTrue("Dew point should be higher at higher humidity", highHumidity > lowHumidity)
}
@Test
fun `calculateDewPoint handles edge cases`() {
// 0% humidity results in NaN (ln(0) = -Infinity, causing invalid calculation)
val zeroHumidity = UnitConversions.calculateDewPoint(20.0f, 0.0f)
assertTrue("Expected NaN for 0% humidity", zeroHumidity.isNaN())
}
}

View file

@ -1,336 +0,0 @@
/*
* Copyright (c) 2025-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.model.util
import co.touchlab.kermit.Logger
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.meshtastic.proto.DeviceMetrics
import org.meshtastic.proto.Position
import org.meshtastic.proto.Telemetry
import org.meshtastic.proto.User
/**
* Unit tests for Wire extension functions.
*
* Tests safe decoding, size validation, and JSON marshalling extensions to ensure proper error handling and
* functionality.
*/
class WireExtensionsTest {
private val testLogger = Logger
@Before
fun setUp() {
// Setup test logger if needed
}
// ===== decodeOrNull() Tests =====
@Test
fun `decodeOrNull with valid ByteString returns decoded message`() {
// Arrange
val position = Position(latitude_i = 371234567, longitude_i = -1220987654, altitude = 15)
val encoded = Position.ADAPTER.encode(position)
val byteString = encoded.toByteString()
// Act
val decoded = Position.ADAPTER.decodeOrNull(byteString, testLogger)
// Assert
assertNotNull(decoded)
assertEquals(position.latitude_i, decoded!!.latitude_i)
assertEquals(position.longitude_i, decoded.longitude_i)
assertEquals(position.altitude, decoded.altitude)
}
@Test
fun `decodeOrNull with null ByteString returns null`() {
// Act
val result = Position.ADAPTER.decodeOrNull(null as ByteString?, testLogger)
// Assert
assertNull(result)
}
@Test
fun `decodeOrNull with empty ByteString returns empty message`() {
// Act
val result = Position.ADAPTER.decodeOrNull(ByteString.EMPTY, testLogger)
// Assert
assertNotNull(result)
// An empty position should have null/default values
assertNull(result!!.latitude_i)
}
@Test
fun `decodeOrNull with valid ByteArray returns decoded message`() {
// Arrange
val position = Position(latitude_i = 371234567, longitude_i = -1220987654)
val encoded = Position.ADAPTER.encode(position)
// Act
val decoded = Position.ADAPTER.decodeOrNull(encoded, testLogger)
// Assert
assertNotNull(decoded)
assertEquals(position.latitude_i, decoded!!.latitude_i)
assertEquals(position.longitude_i, decoded.longitude_i)
}
@Test
fun `decodeOrNull with null ByteArray returns null`() {
// Act
val result = Position.ADAPTER.decodeOrNull(null as ByteArray?, testLogger)
// Assert
assertNull(result)
}
@Test
fun `decodeOrNull with empty ByteArray returns empty message`() {
// Act
val result = Position.ADAPTER.decodeOrNull(ByteArray(0), testLogger)
// Assert
assertNotNull(result)
assertNull(result!!.latitude_i)
}
@Test
fun `decodeOrNull with invalid data returns null`() {
// Arrange
// A single byte 0xFF is an invalid field tag (field 0 is reserved and tags are varints)
val invalidBytes = ByteString.of(0xFF.toByte())
// Act - should not throw, should return null
val result = Position.ADAPTER.decodeOrNull(invalidBytes, testLogger)
// Assert
assertNull(result)
}
// ===== Size Validation Tests =====
@Test
fun `isWithinSizeLimit returns true for message under limit`() {
// Arrange
val position = Position(latitude_i = 371234567)
val limit = 1000
// Act
val isValid = Position.ADAPTER.isWithinSizeLimit(position, limit)
// Assert
assertTrue(isValid)
}
@Test
fun `isWithinSizeLimit returns false for message over limit`() {
// Arrange
val telemetry =
Telemetry(
device_metrics =
DeviceMetrics(voltage = 4.2f, battery_level = 85, air_util_tx = 5.0f, channel_utilization = 15.0f),
)
val limit = 1 // Artificially low limit
// Act
val isValid = Telemetry.ADAPTER.isWithinSizeLimit(telemetry, limit)
// Assert
assertEquals(false, isValid)
}
@Test
fun `sizeInBytes returns accurate encoded size`() {
// Arrange
val position = Position(latitude_i = 371234567, longitude_i = -1220987654)
// Act
val size = Position.ADAPTER.sizeInBytes(position)
val actualEncoded = Position.ADAPTER.encode(position)
// Assert
assertEquals(actualEncoded.size, size)
assertTrue(size > 0)
}
@Test
fun `sizeInBytes for empty message`() {
// Arrange
val emptyPosition = Position()
// Act
val size = Position.ADAPTER.sizeInBytes(emptyPosition)
// Assert
assertTrue(size >= 0)
}
@Test
fun `sizeInBytes matches wire encoding size`() {
// Arrange
val user = User(id = "12345", long_name = "Test User", short_name = "TU")
// Act
val extensionSize = User.ADAPTER.sizeInBytes(user)
val actualEncoded = User.ADAPTER.encode(user)
// Assert
assertEquals(extensionSize, actualEncoded.size)
}
// ===== JSON Marshalling Tests =====
@Test
fun `toReadableString returns non-empty string`() {
// Arrange
val position = Position(latitude_i = 371234567, longitude_i = -1220987654)
// Act
val readable = Position.ADAPTER.toReadableString(position)
// Assert
assertNotNull(readable)
assertTrue(readable.isNotEmpty())
assertTrue(readable.contains("Position"))
}
@Test
fun `toReadableString contains field values`() {
// Arrange
val position = Position(latitude_i = 12345, longitude_i = 67890)
// Act
val readable = Position.ADAPTER.toReadableString(position)
// Assert
assertTrue(readable.contains("12345"))
assertTrue(readable.contains("67890"))
}
@Test
fun `toOneLiner returns single line string`() {
// Arrange
val telemetry = Telemetry(device_metrics = DeviceMetrics(voltage = 4.2f))
// Act
val oneLiner = Telemetry.ADAPTER.toOneLiner(telemetry)
// Assert
assertNotNull(oneLiner)
assertEquals(false, oneLiner.contains("\n"))
assertTrue(oneLiner.isNotEmpty())
}
@Test
fun `toOneLiner contains essential data`() {
// Arrange
val user = User(long_name = "Test User")
// Act
val oneLiner = User.ADAPTER.toOneLiner(user)
// Assert
assertTrue(oneLiner.contains("Test User"))
}
// ===== Integration Tests =====
@Test
fun `decode and encode roundtrip maintains data`() {
// Arrange
val originalPosition =
Position(latitude_i = 371234567, longitude_i = -1220987654, altitude = 15, precision_bits = 5)
val encoded = Position.ADAPTER.encode(originalPosition)
// Act
val decoded = Position.ADAPTER.decodeOrNull(encoded, testLogger)
// Assert
assertNotNull(decoded)
assertEquals(originalPosition.latitude_i, decoded!!.latitude_i)
assertEquals(originalPosition.longitude_i, decoded.longitude_i)
assertEquals(originalPosition.altitude, decoded.altitude)
assertEquals(originalPosition.precision_bits, decoded.precision_bits)
}
@Test
fun `size checking prevents oversized messages`() {
// Arrange
val position = Position(latitude_i = 123456789, longitude_i = 987654321, altitude = 100)
val maxSize = 5 // Very small limit
// Act
val isValid = Position.ADAPTER.isWithinSizeLimit(position, maxSize)
val actualSize = Position.ADAPTER.sizeInBytes(position)
// Assert
assertEquals(false, isValid)
assertTrue(actualSize > maxSize)
}
@Test
fun `multiple messages with different sizes`() {
// Arrange
val smallUser = User(short_name = "A")
val largeUser = User(long_name = "Very Long Name " + "X".repeat(100))
// Act
val smallSize = User.ADAPTER.sizeInBytes(smallUser)
val largeSize = User.ADAPTER.sizeInBytes(largeUser)
// Assert
assertTrue(smallSize < largeSize)
assertTrue(largeSize > smallSize)
}
@Test
fun `readable string format consistency`() {
// Arrange
val position = Position(latitude_i = 123456)
// Act
val readable1 = Position.ADAPTER.toReadableString(position)
val readable2 = Position.ADAPTER.toReadableString(position)
// Assert
assertEquals(readable1, readable2)
}
@Test
fun `oneLiner format consistency`() {
// Arrange
val user = User(long_name = "Test")
// Act
val line1 = User.ADAPTER.toOneLiner(user)
val line2 = User.ADAPTER.toOneLiner(user)
// Assert
assertEquals(line1, line2)
assertEquals(false, line1.contains("\n"))
}
}

View file

@ -23,50 +23,56 @@ import org.meshtastic.core.model.util.isDebug
*
* This class provides a centralized way to check if specific features are supported by the connected node's firmware.
* Add new features here to ensure consistency across the app.
*
* Note: Properties are calculated once during initialization for efficiency.
*/
data class Capabilities(val firmwareVersion: String?, internal val forceEnableAll: Boolean = isDebug) {
private val version = firmwareVersion?.let { DeviceVersion(it) }
private fun isSupported(minVersion: String): Boolean =
forceEnableAll || (version != null && version >= DeviceVersion(minVersion))
private fun atLeast(min: DeviceVersion): Boolean = forceEnableAll || (version != null && version >= min)
/**
* Ability to mute notifications from specific nodes via admin messages.
*
* Note: This is currently not available in firmware but defined here for future support.
*/
val canMuteNode: Boolean
get() = isSupported("2.7.18")
/** Ability to mute notifications from specific nodes via admin messages. */
val canMuteNode = atLeast(V2_7_18)
/** FIXME: Ability to request neighbor information from other nodes. Disabled until working better. */
val canRequestNeighborInfo: Boolean
get() = isSupported("9.9.9")
val canRequestNeighborInfo = atLeast(UNRELEASED)
/** Ability to send verified shared contacts. Supported since firmware v2.7.12. */
val canSendVerifiedContacts: Boolean
get() = isSupported("2.7.12")
val canSendVerifiedContacts = atLeast(V2_7_12)
/** Ability to toggle device telemetry globally via module config. Supported since firmware v2.7.12. */
val canToggleTelemetryEnabled: Boolean
get() = isSupported("2.7.12")
val canToggleTelemetryEnabled = atLeast(V2_7_12)
/** Ability to toggle the 'is_unmessageable' flag in user config. Supported since firmware v2.6.9. */
val canToggleUnmessageable: Boolean
get() = isSupported("2.6.9")
val canToggleUnmessageable = atLeast(V2_6_9)
/** Support for sharing contact information via QR codes. Supported since firmware v2.6.8. */
val supportsQrCodeSharing: Boolean
get() = isSupported("2.6.8")
val supportsQrCodeSharing = atLeast(V2_6_8)
/** Support for Status Message module. Supported since firmware v2.7.17. */
val supportsStatusMessage: Boolean
get() = isSupported("2.7.17")
val supportsStatusMessage = atLeast(V2_7_17)
/** Support for Traffic Management module. Supported since firmware v3.0.0. */
val supportsTrafficManagementConfig = atLeast(V3_0_0)
/** Support for TAK (ATAK) module configuration. Supported since firmware v2.7.19. */
val supportsTakConfig = atLeast(V2_7_19)
/** Support for location sharing on secondary channels. Supported since firmware v2.6.10. */
val supportsSecondaryChannelLocation: Boolean
get() = isSupported("2.6.10")
val supportsSecondaryChannelLocation = atLeast(V2_6_10)
/** Support for ESP32 Unified OTA. Supported since firmware v2.7.18. */
val supportsEsp32Ota: Boolean
get() = isSupported("2.7.18")
val supportsEsp32Ota = atLeast(V2_7_18)
companion object {
private val V2_6_8 = DeviceVersion("2.6.8")
private val V2_6_9 = DeviceVersion("2.6.9")
private val V2_6_10 = DeviceVersion("2.6.10")
private val V2_7_12 = DeviceVersion("2.7.12")
private val V2_7_17 = DeviceVersion("2.7.17")
private val V2_7_18 = DeviceVersion("2.7.18")
private val V2_7_19 = DeviceVersion("2.7.19")
private val V3_0_0 = DeviceVersion("3.0.0")
private val UNRELEASED = DeviceVersion("9.9.9")
}
}

View file

@ -21,15 +21,15 @@ import co.touchlab.kermit.Logger
/** Provide structured access to parse and compare device version strings */
data class DeviceVersion(val asString: String) : Comparable<DeviceVersion> {
/** The integer representation of the version (e.g., 2.7.12 -> 20712). Calculated once. */
@Suppress("TooGenericExceptionCaught", "SwallowedException")
val asInt
get() =
try {
verStringToInt(asString)
} catch (e: Exception) {
Logger.w { "Exception while parsing version '$asString', assuming version 0" }
0
}
val asInt: Int =
try {
verStringToInt(asString)
} catch (e: Exception) {
Logger.w { "Exception while parsing version '$asString', assuming version 0" }
0
}
/**
* Convert a version string of the form 1.23.57 to a comparable integer of the form 12357.
@ -51,5 +51,5 @@ data class DeviceVersion(val asString: String) : Comparable<DeviceVersion> {
return major.toInt() * 10000 + minor.toInt() * 100 + build.toInt()
}
override fun compareTo(other: DeviceVersion): Int = asInt - other.asInt
override fun compareTo(other: DeviceVersion): Int = asInt.compareTo(other.asInt)
}