Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion core/strings/src/commonMain/composeResources/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1006,4 +1006,17 @@
<string name="firmware_update_disclaimer_text">You are about to flash new firmware to your device. This process carries risks.\n\n• Ensure your device is charged.\n• Keep the device close to your phone.\n• Do not close the app during the update.\n\nVerify you have selected the correct firmware for your hardware.</string>
<string name="firmware_update_disclaimer_chirpy_says">Chirpy says, "Keep your ladder handy!"</string>
<string name="chirpy">Chirpy</string>
</resources>

<!-- Compass -->
<string name="compass_title">Compass</string>
<string name="open_compass">Open Compass</string>
<string name="compass_distance">Distance: %1$s</string>
<string name="compass_bearing">Bearing: %1$s</string>
<string name="compass_bearing_na">Bearing: N/A</string>
<string name="compass_no_magnetometer">This device does not have a compass sensor. Heading is unavailable.</string>
<string name="compass_no_location_permission">Location permission is required to show distance and bearing.</string>
<string name="compass_location_disabled">Location provider is disabled. Turn on location services.</string>
<string name="compass_no_location_fix">Waiting for a GPS fix to calculate distance and bearing.</string>
<string name="compass_uncertainty">Estimated area: \u00b1%1$s (\u00b1%2$s)</string>
<string name="compass_uncertainty_unknown">Estimated area: unknown accuracy</string>
</resources>
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/*
* Copyright (c) 2025 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.feature.node.compass

import android.content.Context
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import android.hardware.GeomagneticField
import android.location.Location
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import javax.inject.Inject

private const val ROTATION_MATRIX_SIZE = 9
private const val ORIENTATION_SIZE = 3
private const val DEGREES_IN_CIRCLE = 360f

data class HeadingState(
val heading: Float? = null, // 0..360 degrees
val hasSensor: Boolean = true,
val accuracy: Int = SensorManager.SENSOR_STATUS_ACCURACY_MEDIUM,
)

class CompassHeadingProvider @Inject constructor(@ApplicationContext private val context: Context) {

/**
* Emits compass heading in degrees. If a location is provided, applies true north correction.
*
* @param location Optional Location from the phone's location provider.
*/
fun headingUpdates(location: Location? = null): Flow<HeadingState> = callbackFlow {
Copy link

Copilot AI Dec 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The headingUpdates function accepts a location parameter but never uses it because it's captured once at function creation time. However, the location needs to be continuously updated for accurate true north correction as the phone moves. Consider either removing the parameter and obtaining location dynamically, or restructuring to accept a Flow<Location?> that can be combined with sensor updates.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Had codex implement a suggested fix (on next push)

val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as? SensorManager
if (sensorManager == null) {
trySend(HeadingState(hasSensor = false))
close()
return@callbackFlow
}

val rotationSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
val accelerometer = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
val magnetometer = sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD)

if (rotationSensor == null && (accelerometer == null || magnetometer == null)) {
trySend(HeadingState(hasSensor = false))
awaitClose {}
return@callbackFlow
}

val rotationMatrix = FloatArray(ROTATION_MATRIX_SIZE)
val orientation = FloatArray(ORIENTATION_SIZE)
val accelValues = FloatArray(ORIENTATION_SIZE)
val magnetValues = FloatArray(ORIENTATION_SIZE)
var hasAccel = false
var hasMagnet = false

val listener = object : SensorEventListener {
override fun onSensorChanged(event: SensorEvent) {
when (event.sensor.type) {
Sensor.TYPE_ROTATION_VECTOR -> {
SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values)
}
Sensor.TYPE_ACCELEROMETER -> {
System.arraycopy(event.values, 0, accelValues, 0, accelValues.size)
hasAccel = true
}
Sensor.TYPE_MAGNETIC_FIELD -> {
System.arraycopy(event.values, 0, magnetValues, 0, magnetValues.size)
hasMagnet = true
}
}

val ready = rotationSensor != null || (hasAccel && hasMagnet)
if (ready) {
if (rotationSensor == null) {
SensorManager.getRotationMatrix(rotationMatrix, null, accelValues, magnetValues)
}

SensorManager.getOrientation(rotationMatrix, orientation)
var azimuth = Math.toDegrees(orientation[0].toDouble()).toFloat()
var heading = (azimuth + 360) % 360

// True north correction using phone location if available
location?.let { loc ->
val geomagnetic = GeomagneticField(
loc.latitude.toFloat(),
loc.longitude.toFloat(),
loc.altitude.toFloat(),
System.currentTimeMillis()
)
heading = (heading + geomagnetic.declination + 360) % 360
}

trySend(
HeadingState(
heading = heading,
hasSensor = true,
accuracy = event.accuracy
)
)
}
}

override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {
// No-op
}
}

rotationSensor?.let { sensorManager.registerListener(listener, it, SensorManager.SENSOR_DELAY_UI) }
if (rotationSensor == null) {
accelerometer?.let { sensorManager.registerListener(listener, it, SensorManager.SENSOR_DELAY_UI) }
magnetometer?.let { sensorManager.registerListener(listener, it, SensorManager.SENSOR_DELAY_UI) }
}

awaitClose { sensorManager.unregisterListener(listener) }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/*
* Copyright (c) 2025 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.feature.node.compass

import androidx.compose.ui.graphics.Color
import org.meshtastic.proto.ConfigProtos.Config.DisplayConfig.DisplayUnits

private const val DEFAULT_TARGET_COLOR_HEX = 0xFFFF9800

enum class CompassWarning {
NO_MAGNETOMETER,
NO_LOCATION_PERMISSION,
LOCATION_DISABLED,
NO_LOCATION_FIX,
}

/** Render-ready state for the compass sheet (heading, bearing, distances, and warnings). */
data class CompassUiState(
val targetName: String = "",
val targetColor: Color = Color(DEFAULT_TARGET_COLOR_HEX),
val heading: Float? = null,
val bearing: Float? = null,
val distanceText: String? = null,
val bearingText: String? = null,
val lastUpdateText: String? = null,
val positionTimeSec: Long? = null, // Epoch seconds for the target position (used for elapsed display)
val warnings: List<CompassWarning> = emptyList(),
val errorRadiusText: String? = null,
val angularErrorDeg: Float? = null,
val isAligned: Boolean = false,
val hasTargetPosition: Boolean = true,
val displayUnits: DisplayUnits = DisplayUnits.METRIC,
)
Loading