From 2b2455dbb32f60780f99a1238e148fd4bfa51368 Mon Sep 17 00:00:00 2001 From: Tim Kluge Date: Fri, 3 Oct 2025 19:25:25 +0200 Subject: [PATCH] Use magnetometer to determine bearing, add compass datafield --- README.md | 1 + app/build.gradle.kts | 2 +- .../de/timklge/karooheadwind/HeadingFlow.kt | 99 +++++++++- .../karooheadwind/KarooHeadwindExtension.kt | 4 +- .../datatypes/CompassDataType.kt | 183 ++++++++++++++++++ .../WindDirectionAndSpeedDataType.kt | 2 +- app/src/main/res/values/strings.xml | 2 + app/src/main/res/xml/extension_info.xml | 7 + .../de/timklge/karooheadwind/DataStoreTest.kt | 64 ++++++ 9 files changed, 355 insertions(+), 9 deletions(-) create mode 100644 app/src/main/kotlin/de/timklge/karooheadwind/datatypes/CompassDataType.kt create mode 100644 app/src/test/kotlin/de/timklge/karooheadwind/DataStoreTest.kt diff --git a/README.md b/README.md index 1a799c0..6664d8e 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ After installing this app on your Karoo and opening it once from the main menu, - Relative grade (numerical): Shows the relative grade. The relative grade is calculated by estimating the force of the headwind, and then calculating the gradient you would need to ride at to experience this resistance if there was no wind. Example: If you are riding on an actual gradient of 2 %, face a headwind of 18 km/h while riding at 29 km/h, the relative grade will be shown as 5.2 % (with 3.2 % added to the actual grade due to the headwind). - Relative elevation gain (numerical): Shows the relative elegation gain. The relative elevation gain is calculated using the relative grade and is an estimation of how much climbing would have been equivalent to the headwind you faced during the ride. - Resistance forces (graphical, 2x1 field): Shows a graphical representation of the different forces you have to overcome while riding, including gravity (actual gradient), rolling resistance (based on speed and weight), aerodynamic drag (based on speed) and wind resistance (based on headwind speed). The app reads your weight from your karoo user profile and uses rough estimates for CdA and Crr. +- Compass (graphical, 1x1 field): Shows a compass needle that uses the Karoo's magnetometer (red points north). - Additionally, data fields that only show the current data value for headwind speed, humidity, cloud cover, absolute wind speed, absolute wind gust speed, absolute wind direction, rainfall and surface pressure can be added if desired. The app can use OpenMeteo or OpenWeatherMap as providers for live weather data. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7eced04..4cc843f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -72,7 +72,7 @@ tasks.register("generateManifest") { "latestVersionCode" to android.defaultConfig.versionCode, "developer" to "github.com/timklge", "description" to "Open-source extension that provides headwind direction, wind speed, forecast and other weather data fields.", - "releaseNotes" to "* Open main extension menu when clicking on graphical headwind / tailwind datafield\n* Only update position if the estimated accuracy is within 500 meters\n* Add force distribution datafield\n* Only increase relative elevation gain when relative grade is positive", + "releaseNotes" to "* Add compass data field\n* Use magnetometer to determine heading", "screenshotUrls" to listOf( "$baseUrl/preview1.png", "$baseUrl/preview3.png", diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/HeadingFlow.kt b/app/src/main/kotlin/de/timklge/karooheadwind/HeadingFlow.kt index 1ba1cd9..c5a0bff 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/HeadingFlow.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/HeadingFlow.kt @@ -1,14 +1,20 @@ package de.timklge.karooheadwind import android.content.Context +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager import android.util.Log import de.timklge.karooheadwind.datatypes.GpsCoordinates import de.timklge.karooheadwind.util.signedAngleDifference import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.models.DataType import io.hammerhead.karooext.models.StreamState +import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.emitAll @@ -17,6 +23,7 @@ import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull +import java.time.Instant sealed class HeadingResponse { data object NoGps: HeadingResponse() @@ -47,12 +54,10 @@ fun KarooSystemService.getRelativeHeadingFlow(context: Context): Flow { - // return flowOf(HeadingResponse.Value(20.0)) - - return getGpsCoordinateFlow(context) - .map { coords -> - val heading = coords?.bearing - Log.d(KarooHeadwindExtension.TAG, "Updated gps bearing: $heading") + // Use magnetometer heading instead of GPS bearing + return getMagnetometerHeadingFlow(context) + .map { heading -> + Log.d(KarooHeadwindExtension.TAG, "Updated magnetometer heading: $heading") val headingValue = heading?.let { HeadingResponse.Value(it) } headingValue ?: HeadingResponse.NoGps @@ -156,3 +161,85 @@ fun KarooSystemService.getGpsCoordinateFlow(context: Context): Flow = callbackFlow { + val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager + val rotationVectorSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR) + + Log.d(KarooHeadwindExtension.TAG, "Using magnetometer for heading") + + if (rotationVectorSensor == null) { + Log.w(KarooHeadwindExtension.TAG, "Rotation vector sensor not available, falling back to GPS bearing") + + // Fall back to GPS bearing + getGpsCoordinateFlow(context) + .collect { coords -> + val bearing = coords?.bearing + if (bearing != null) { + trySend(bearing) + } else { + trySend(null) + } + } + return@callbackFlow + } + + var lastEventReceived: Instant? = null + + val listener = object : SensorEventListener { + override fun onSensorChanged(event: SensorEvent?) { + if (event == null){ + Log.w(KarooHeadwindExtension.TAG, "Received null sensor event") + return + } + + if (event.sensor.type == Sensor.TYPE_ROTATION_VECTOR) { + if (lastEventReceived != null){ + val now = Instant.now() + val duration = java.time.Duration.between(lastEventReceived, now).toMillis() + if (duration < 500){ + // Throttle to max 2 updates per second + return + } + lastEventReceived = now + } else { + lastEventReceived = Instant.now() + } + + val rotationMatrix = FloatArray(9) + val orientationAngles = FloatArray(3) + + // Convert rotation vector to rotation matrix + SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values) + + // Get orientation angles from rotation matrix + SensorManager.getOrientation(rotationMatrix, orientationAngles) + + // Azimuth in radians, convert to degrees + val azimuthRad = orientationAngles[0] + var azimuthDeg = Math.toDegrees(azimuthRad.toDouble()) + + // Normalize to 0-360 range + if (azimuthDeg < 0) { + azimuthDeg += 360.0 + } + + // Log.d(KarooHeadwindExtension.TAG, "Rotation vector heading: $azimuthDeg degrees") + + trySend(azimuthDeg) + } + } + + override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) { + Log.d(KarooHeadwindExtension.TAG, "Sensor accuracy changed: ${sensor?.name}, accuracy: $accuracy") + } + } + + // Register listener for rotation vector sensor + sensorManager.registerListener(listener, rotationVectorSensor, SensorManager.SENSOR_DELAY_UI) + + awaitClose { + sensorManager.unregisterListener(listener) + Log.d(KarooHeadwindExtension.TAG, "Rotation vector listener unregistered") + } +} diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/KarooHeadwindExtension.kt b/app/src/main/kotlin/de/timklge/karooheadwind/KarooHeadwindExtension.kt index c6b0488..b8169ee 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/KarooHeadwindExtension.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/KarooHeadwindExtension.kt @@ -5,6 +5,7 @@ import com.mapbox.geojson.LineString import com.mapbox.turf.TurfConstants import com.mapbox.turf.TurfMeasurement import de.timklge.karooheadwind.datatypes.CloudCoverDataType +import de.timklge.karooheadwind.datatypes.CompassDataType import de.timklge.karooheadwind.datatypes.GpsCoordinates import de.timklge.karooheadwind.datatypes.HeadwindDirectionDataType import de.timklge.karooheadwind.datatypes.HeadwindForecastDataType @@ -89,7 +90,8 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", BuildConfig.VERS RelativeElevationGainDataType(karooSystem, applicationContext), TemperatureDataType(karooSystem, applicationContext), UviDataType(karooSystem, applicationContext), - ResistanceForcesDataType(karooSystem, applicationContext) + ResistanceForcesDataType(karooSystem, applicationContext), + CompassDataType(karooSystem, applicationContext) ) } diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/CompassDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/CompassDataType.kt new file mode 100644 index 0000000..301f128 --- /dev/null +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/CompassDataType.kt @@ -0,0 +1,183 @@ +package de.timklge.karooheadwind.datatypes + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Path +import android.util.Log +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.core.graphics.createBitmap +import androidx.glance.GlanceModifier +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.action.actionStartActivity +import androidx.glance.action.clickable +import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi +import androidx.glance.appwidget.GlanceRemoteViews +import androidx.glance.appwidget.background +import androidx.glance.appwidget.cornerRadius +import androidx.glance.layout.Box +import androidx.glance.layout.ContentScale +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.padding +import de.timklge.karooheadwind.HeadingResponse +import de.timklge.karooheadwind.KarooHeadwindExtension +import de.timklge.karooheadwind.MainActivity +import de.timklge.karooheadwind.getHeadingFlow +import de.timklge.karooheadwind.screens.isNightMode +import de.timklge.karooheadwind.streamDatatypeIsVisible +import de.timklge.karooheadwind.throttle +import io.hammerhead.karooext.KarooSystemService +import io.hammerhead.karooext.extension.DataTypeImpl +import io.hammerhead.karooext.internal.ViewEmitter +import io.hammerhead.karooext.models.ShowCustomStreamState +import io.hammerhead.karooext.models.UpdateGraphicConfig +import io.hammerhead.karooext.models.ViewConfig +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch + +class CompassDataType( + private val karooSystem: KarooSystemService, + private val applicationContext: Context +) : DataTypeImpl("karoo-headwind", "compass") { + @OptIn(ExperimentalGlanceRemoteViewsApi::class) + private val glance = GlanceRemoteViews() + + data class StreamData(val bearing: Float, val isVisible: Boolean) + + private fun drawCompassNeedle(bitmap: Bitmap, baseColor: Int) { + val canvas = Canvas(bitmap) + val centerX = bitmap.width / 2f + val centerY = bitmap.height / 2f + + // Draw black needle pointing south (downward) + val blackPaint = Paint().apply { + color = baseColor + style = Paint.Style.FILL + isAntiAlias = true + } + + val southNeedle = Path().apply { + moveTo(centerX, centerY * 1.9f) // Bottom point + lineTo(centerX - 12f, centerY) // Left base + lineTo(centerX + 12f, centerY) // Right base + close() + } + canvas.drawPath(southNeedle, blackPaint) + + // Draw red needle pointing north (upward) SECOND so it's on top + val redPaint = Paint().apply { + color = android.graphics.Color.RED + style = Paint.Style.FILL + isAntiAlias = true + } + + val northNeedle = Path().apply { + moveTo(centerX, centerY * 0.1f) // Top point + lineTo(centerX - 12f, centerY) // Left base + lineTo(centerX + 12f, centerY) // Right base + close() + } + canvas.drawPath(northNeedle, redPaint) + + // Draw text labels + val textPaint = Paint().apply { + color = baseColor + textSize = 24f + textAlign = Paint.Align.CENTER + isAntiAlias = true + isFakeBoldText = true + } + + // Draw "N" at the top + // canvas.drawText("N", centerX, centerY * 0.3f, textPaint) + + // Draw "S" at the bottom + // canvas.drawText("S", centerX, centerY * 1.7f, textPaint) + + // Draw center circle + val centerPaint = Paint().apply { + color = android.graphics.Color.DKGRAY + style = Paint.Style.FILL + isAntiAlias = true + } + canvas.drawCircle(centerX, centerY, 8f, centerPaint) + } + + private fun previewFlow(): Flow { + return flow { + while (true) { + emit(StreamData(isVisible = true, bearing = 360f * Math.random().toFloat())) + delay(10_000) + } + } + } + + @OptIn(ExperimentalGlanceRemoteViewsApi::class) + override fun startView(context: Context, config: ViewConfig, emitter: ViewEmitter) { + Log.d(KarooHeadwindExtension.TAG, "Starting compass view with $emitter") + + val baseModifier = GlanceModifier.fillMaxSize().padding(2.dp).background(Color.White, Color.Black).cornerRadius(10.dp) + + val configJob = CoroutineScope(Dispatchers.IO).launch { + emitter.onNext(UpdateGraphicConfig(showHeader = false)) + awaitCancellation() + } + + val isNight = isNightMode(context) + + val baseBitmap = createBitmap(128, 128).also { + drawCompassNeedle(it, if (isNight) android.graphics.Color.WHITE else android.graphics.Color.BLACK) + } + + val flow = if (config.preview) { + previewFlow() + } else { + combine(karooSystem.getHeadingFlow(context), karooSystem.streamDatatypeIsVisible(dataTypeId)){ bearingResponse, isVisible -> + val bearing = when(bearingResponse) { + is HeadingResponse.Value -> ((bearingResponse.diff + 360) % 360).toFloat() + else -> null + } + + StreamData(bearing = bearing ?: 0f, isVisible = isVisible) + } + } + + val viewJob = CoroutineScope(Dispatchers.IO).launch { + emitter.onNext(ShowCustomStreamState("", null)) + + val refreshRate = karooSystem.getRefreshRateInMilliseconds(context) + flow.filter { it.isVisible }.throttle(refreshRate).collect { streamData -> + val result = glance.compose(context, androidx.compose.ui.unit.DpSize.Unspecified) { + Box( + modifier = if (!config.preview) baseModifier.clickable(actionStartActivity()) else baseModifier, + ) { + Image( + modifier = GlanceModifier.fillMaxSize(), + provider = ImageProvider(getArrowBitmapByBearing(baseBitmap, streamData.bearing.toInt())), + contentDescription = "Relative wind direction indicator", + contentScale = ContentScale.Fit, + ) + } + } + + emitter.updateView(result.remoteViews) + } + } + + emitter.setCancellable { + Log.d(KarooHeadwindExtension.TAG, "Stopping compass view with $emitter") + configJob.cancel() + viewJob.cancel() + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindDirectionAndSpeedDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindDirectionAndSpeedDataType.kt index 0cc9c67..c564c37 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindDirectionAndSpeedDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindDirectionAndSpeedDataType.kt @@ -72,7 +72,7 @@ class WindDirectionAndSpeedDataType( @OptIn(ExperimentalGlanceRemoteViewsApi::class) override fun startView(context: Context, config: ViewConfig, emitter: ViewEmitter) { - Log.d(KarooHeadwindExtension.TAG, "Starting headwind direction view with $emitter") + Log.d(KarooHeadwindExtension.TAG, "Starting wind direction and speed view with $emitter") val baseBitmap = BitmapFactory.decodeResource( context.resources, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 95c3e86..545e8f3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -49,4 +49,6 @@ Current wind direction and wind speed (Circle graphics) Resistance forces Current resistance forces (air, rolling, gradient) + Compass + Current compass direction \ No newline at end of file diff --git a/app/src/main/res/xml/extension_info.xml b/app/src/main/res/xml/extension_info.xml index 8189a7e..ae85c10 100644 --- a/app/src/main/res/xml/extension_info.xml +++ b/app/src/main/res/xml/extension_info.xml @@ -165,4 +165,11 @@ graphical="true" icon="@drawable/wind" typeId="forces" /> + + diff --git a/app/src/test/kotlin/de/timklge/karooheadwind/DataStoreTest.kt b/app/src/test/kotlin/de/timklge/karooheadwind/DataStoreTest.kt new file mode 100644 index 0000000..e3f287c --- /dev/null +++ b/app/src/test/kotlin/de/timklge/karooheadwind/DataStoreTest.kt @@ -0,0 +1,64 @@ +package de.timklge.karooheadwind + +import org.junit.Test +import org.junit.Assert.assertEquals + +class DataStoreTest { + + @Test + fun testLerpAngle_normalCase() { + // Normal case (no wrap-around) + val result = lerpAngle(90.0, 180.0, 0.5) + assertEquals(135.0, result, 0.001) + } + + @Test + fun testLerpAngle_wrapAround0To360() { + // Wrap around 0/360 degrees + val result = lerpAngle(350.0, 10.0, 0.5) + assertEquals(0.0, result, 0.001) + } + + @Test + fun testLerpAngle_wrapAround360To0() { + // Wrap around 0/360 degrees (reverse) + val result = lerpAngle(10.0, 350.0, 0.5) + assertEquals(0.0, result, 0.001) + } + + @Test + fun testLerpAngle_largeAngleDifference() { + // Large angle difference (should take shorter path) + val result = lerpAngle(45.0, 315.0, 0.5) + assertEquals(0.0, result, 0.001) + } + + @Test + fun testLerpAngle_exactly180DegreesApart() { + // Exactly 180 degrees apart + assertEquals(90.0, lerpAngle(0.0, 180.0, 0.5), 0.001) + assertEquals(90.0, lerpAngle(180.0, 0.0, 0.5), 0.001) + } + + @Test + fun testLerpAngle_factorBoundaries() { + // Factor = 0 and 1 + assertEquals(350.0, lerpAngle(350.0, 10.0, 0.0), 0.001) + assertEquals(0.0, lerpAngle(350.0, 10.0, 0.5), 0.001) + assertEquals(10.0, lerpAngle(350.0, 10.0, 1.0), 0.001) + } + + @Test + fun testLerpAngle_negativeAngles() { + // Negative angles + val result = lerpAngle(-10.0, 10.0, 0.5) + assertEquals(0.0, result, 0.001) + } + + @Test + fun testLerpAngle_anglesGreaterThan360() { + // Angles > 360 + val result = lerpAngle(370.0, 380.0, 0.5) + assertEquals(15.0, result, 0.001) + } +}