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) + } +}