From c7d7bc766d8609ed9a0aa3fb995900be04df44d8 Mon Sep 17 00:00:00 2001
From: James Rich <2199651+jamesarich@users.noreply.github.com>
Date: Tue, 29 Sep 2026 11:08:35 +0000
Subject: [PATCH] fix(service): correct phone position units and omit missing
readings (#7437)
---
.../service/AndroidMeshLocationManager.kt | 33 +++----
.../meshtastic/core/service/PhonePosition.kt | 49 ++++++++++
.../core/service/PhonePositionTest.kt | 91 +++++++++++++++++++
3 files changed, 154 insertions(+), 19 deletions(-)
create mode 100644 core/service/src/commonMain/kotlin/org/meshtastic/core/service/PhonePosition.kt
create mode 100644 core/service/src/commonTest/kotlin/org/meshtastic/core/service/PhonePositionTest.kt
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidMeshLocationManager.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidMeshLocationManager.kt
index 54d13f6e21..6ccfc6ac52 100644
--- a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidMeshLocationManager.kt
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/AndroidMeshLocationManager.kt
@@ -26,10 +26,8 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.koin.core.annotation.Single
import org.meshtastic.core.common.hasLocationPermission
-import org.meshtastic.core.model.Position
import org.meshtastic.core.repository.LocationRepository
import org.meshtastic.core.repository.MeshLocationManager
-import kotlin.time.Duration.Companion.milliseconds
import org.meshtastic.proto.Position as ProtoPosition
@Single
@@ -51,23 +49,20 @@ class AndroidMeshLocationManager(private val context: Application, private val l
.getLocations()
.onEach { location ->
sendPositionFn(
- ProtoPosition.Builder()
- .also { wb ->
- wb.latitude_i = Position.degI(location.latitude)
- wb.longitude_i = Position.degI(location.longitude)
- wb.altitude =
- if (LocationCompat.hasMslAltitude(location)) {
- LocationCompat.getMslAltitudeMeters(location).toInt()
- } else {
- null
- }
- wb.altitude_hae = location.altitude.toInt()
- wb.time = (location.time.milliseconds.inWholeSeconds).toInt()
- wb.ground_speed = location.speed.toInt()
- wb.ground_track = location.bearing.toInt()
- wb.location_source = ProtoPosition.LocSource.LOC_EXTERNAL
- }
- .build(),
+ phonePosition(
+ latitude = location.latitude,
+ longitude = location.longitude,
+ timeMillis = location.time,
+ mslAltitudeMeters =
+ if (LocationCompat.hasMslAltitude(location)) {
+ LocationCompat.getMslAltitudeMeters(location)
+ } else {
+ null
+ },
+ haeAltitudeMeters = location.altitude.takeIf { location.hasAltitude() },
+ speedMetersPerSecond = location.speed.takeIf { location.hasSpeed() },
+ bearingDegrees = location.bearing.takeIf { location.hasBearing() },
+ ),
)
}
.launchIn(scope)
diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/PhonePosition.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/PhonePosition.kt
new file mode 100644
index 0000000000..86bcffb1a8
--- /dev/null
+++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/PhonePosition.kt
@@ -0,0 +1,49 @@
+/*
+ * 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 .
+ */
+package org.meshtastic.core.service
+
+import org.meshtastic.core.model.Position
+import org.meshtastic.core.model.util.GeoConstants
+import org.meshtastic.core.model.util.UnitConversions
+import kotlin.math.roundToInt
+import kotlin.time.Duration.Companion.milliseconds
+import org.meshtastic.proto.Position as ProtoPosition
+
+/**
+ * The position the phone reports for its node. A reading the fix lacks stays unset rather than going out as 0, and
+ * speed and track use the units firmware's own GPS sends: km/h and 1e-5 degrees.
+ */
+internal fun phonePosition(
+ latitude: Double,
+ longitude: Double,
+ timeMillis: Long,
+ mslAltitudeMeters: Double?,
+ haeAltitudeMeters: Double?,
+ speedMetersPerSecond: Float?,
+ bearingDegrees: Float?,
+): ProtoPosition = ProtoPosition.Builder()
+ .also { wb ->
+ wb.latitude_i = Position.degI(latitude)
+ wb.longitude_i = Position.degI(longitude)
+ wb.altitude = mslAltitudeMeters?.toInt()
+ wb.altitude_hae = haeAltitudeMeters?.toInt()
+ wb.time = timeMillis.milliseconds.inWholeSeconds.toInt()
+ wb.ground_speed = speedMetersPerSecond?.let { UnitConversions.metersPerSecondToKph(it).roundToInt() }
+ wb.ground_track = bearingDegrees?.let { (it / GeoConstants.HEADING_DEG).roundToInt() }
+ wb.location_source = ProtoPosition.LocSource.LOC_EXTERNAL
+ }
+ .build()
diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/PhonePositionTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/PhonePositionTest.kt
new file mode 100644
index 0000000000..65eafbcf7a
--- /dev/null
+++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/PhonePositionTest.kt
@@ -0,0 +1,91 @@
+/*
+ * 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 .
+ */
+package org.meshtastic.core.service
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import org.meshtastic.proto.Position as ProtoPosition
+
+class PhonePositionTest {
+
+ private fun fix(
+ mslAltitudeMeters: Double? = null,
+ haeAltitudeMeters: Double? = null,
+ speedMetersPerSecond: Float? = null,
+ bearingDegrees: Float? = null,
+ ) = phonePosition(
+ latitude = 1.5,
+ longitude = -2.25,
+ timeMillis = 1_700_000_000_500,
+ mslAltitudeMeters = mslAltitudeMeters,
+ haeAltitudeMeters = haeAltitudeMeters,
+ speedMetersPerSecond = speedMetersPerSecond,
+ bearingDegrees = bearingDegrees,
+ )
+
+ @Test
+ fun `readings the fix lacks stay unset`() {
+ val position = fix()
+
+ assertNull(position.altitude)
+ assertNull(position.altitude_hae)
+ assertNull(position.ground_speed)
+ assertNull(position.ground_track)
+ }
+
+ @Test
+ fun `zero readings are sent as zero`() {
+ val position =
+ fix(mslAltitudeMeters = 0.0, haeAltitudeMeters = 0.0, speedMetersPerSecond = 0f, bearingDegrees = 0f)
+
+ assertEquals(0, position.altitude)
+ assertEquals(0, position.altitude_hae)
+ assertEquals(0, position.ground_speed)
+ assertEquals(0, position.ground_track)
+ }
+
+ @Test
+ fun `speed goes out in km per hour`() {
+ assertEquals(36, fix(speedMetersPerSecond = 10f).ground_speed)
+ assertEquals(5, fix(speedMetersPerSecond = 1.4f).ground_speed)
+ }
+
+ @Test
+ fun `track goes out in hundred-thousandths of a degree`() {
+ assertEquals(9_000_000, fix(bearingDegrees = 90f).ground_track)
+ assertEquals(35_950_000, fix(bearingDegrees = 359.5f).ground_track)
+ }
+
+ @Test
+ fun `each altitude keeps its own datum`() {
+ val position = fix(mslAltitudeMeters = 12.9, haeAltitudeMeters = -20.4)
+
+ assertEquals(12, position.altitude)
+ assertEquals(-20, position.altitude_hae)
+ }
+
+ @Test
+ fun `coordinates and time use the wire scale`() {
+ val position = fix()
+
+ assertEquals(15_000_000, position.latitude_i)
+ assertEquals(-22_500_000, position.longitude_i)
+ assertEquals(1_700_000_000, position.time)
+ assertEquals(ProtoPosition.LocSource.LOC_EXTERNAL, position.location_source)
+ }
+}