Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 0 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,6 @@ The app will automatically attempt to download weather data from the selected da
New weather data is downloaded when you ride more than three kilometers from the location where the weather data was downloaded for or after one hour at the latest.
If the app cannot connect to the weather service, it will retry the download every minute. Downloading weather data should work on Karoo 2 if you have a SIM card inserted or on Karoo 3 via your phone's internet connection if you have the Karoo companion app installed.

By default, the app will use GPS bearing to determine your riding direction. You can switch to using the Karoo's magnetometer in the settings menu.

If you are connected to WiFi, you can open an embedded [windy.com](https://www.windy.com) map to see a detailed wind forecast for your area. You can zoom and pan the map as desired.

## Credits
Expand Down
180 changes: 6 additions & 174 deletions app/src/main/kotlin/de/timklge/karooheadwind/HeadingFlow.kt
Original file line number Diff line number Diff line change
@@ -1,36 +1,22 @@
package de.timklge.karooheadwind

import android.content.Context
import android.hardware.GeomagneticField
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.Dispatchers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
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
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.shareIn
import kotlinx.coroutines.flow.SharingStarted
import java.time.Instant

sealed class HeadingResponse {
Expand Down Expand Up @@ -68,29 +54,13 @@ fun KarooSystemService.getRelativeHeadingFlow(context: Context): Flow<HeadingRes

@OptIn(ExperimentalCoroutinesApi::class)
fun KarooSystemService.getHeadingFlow(karooSystemService: KarooSystemService, context: Context): Flow<HeadingResponse> {
return context.streamSettings(karooSystemService).map { it.useMagnetometerForHeading }.distinctUntilChanged().flatMapLatest { useMagnetometerForHeading ->
if (useMagnetometerForHeading) {
Log.i(KarooHeadwindExtension.TAG, "Using magnetometer for heading as per settings")
return getGpsCoordinateFlow(context).map { gps ->
if (gps != null) {
val headingValue = gps.bearing?.let { HeadingResponse.Value(it) }

getMagnetometerHeadingFlow(context)
.map { heading ->
val headingValue = heading?.let { HeadingResponse.Value(it) }

headingValue ?: HeadingResponse.NoGps
}
.distinctUntilChanged()
headingValue ?: HeadingResponse.NoGps
} else {
Log.i(KarooHeadwindExtension.TAG, "Using GPS bearing for heading as per settings")

getGpsCoordinateFlow(context).map { gps ->
if (gps != null) {
val headingValue = gps.bearing?.let { HeadingResponse.Value(it) }

headingValue ?: HeadingResponse.NoGps
} else {
HeadingResponse.NoGps
}
}
HeadingResponse.NoGps
}
}
}
Expand Down Expand Up @@ -190,142 +160,4 @@ fun KarooSystemService.getGpsCoordinateFlow(context: Context): Flow<GpsCoordinat
gps?.round(settings.roundLocationTo.km.toDouble())
}
.dropNullsIfNullEncountered()
}

// Shared magnetometer sensor data holder
private object MagnetometerSensorHolder {
@Volatile
private var sharedMagnetometerFlow: Flow<FloatArray?>? = null
private val lock = Any()

fun getSharedSensorFlow(context: Context): Flow<FloatArray?> {
return sharedMagnetometerFlow ?: synchronized(lock) {
sharedMagnetometerFlow ?: createSharedSensorFlow(context).also {
sharedMagnetometerFlow = it
}
}
}

private fun createSharedSensorFlow(context: Context): Flow<FloatArray?> {
val sensorManager = context.getSystemService(Context.SENSOR_SERVICE) as SensorManager
val rotationVectorSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)

if (rotationVectorSensor == null) {
Log.w(KarooHeadwindExtension.TAG, "Rotation vector sensor not available")
return flow { emit(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) {
// Throttle to max one event every 750ms
if (lastEventReceived != null) {
val now = Instant.now()
val duration = java.time.Duration.between(lastEventReceived, now).toMillis()
if (duration < 750) {
return
}
lastEventReceived = now
} else {
lastEventReceived = Instant.now()
}

Log.d(KarooHeadwindExtension.TAG, "Received rotation vector sensor event: ${event.values.joinToString(",")}")

trySend(event.values.copyOf())
}
}

override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {
Log.d(KarooHeadwindExtension.TAG, "Sensor accuracy changed: ${sensor?.name}, accuracy: $accuracy")
}
}

Log.d(KarooHeadwindExtension.TAG, "Registering rotation vector sensor listener")
sensorManager.registerListener(listener, rotationVectorSensor, SensorManager.SENSOR_DELAY_NORMAL, 500_000) // 500ms

awaitClose {
sensorManager.unregisterListener(listener)
Log.d(KarooHeadwindExtension.TAG, "Rotation vector listener unregistered")
}
}.shareIn(
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000, replayExpirationMillis = 0),
replay = 1
)
}
}

fun KarooSystemService.getMagnetometerHeadingFlow(context: Context): Flow<Double?> {
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
return getGpsCoordinateFlow(context).map { coords -> coords?.bearing }
}

// Combine GPS coordinates with sensor data
return MagnetometerSensorHolder.getSharedSensorFlow(context)
.combine(getGpsCoordinateFlow(context)) { sensorValues, gpsCoordinates ->
sensorValues to gpsCoordinates
}
.map { (sensorValues, gpsCoordinates) ->
if (sensorValues == null) {
return@map null
}

val rotationMatrix = FloatArray(9)
val orientationAngles = FloatArray(3)

// Convert rotation vector to rotation matrix
SensorManager.getRotationMatrixFromVector(rotationMatrix, sensorValues)

// Get orientation angles from rotation matrix
SensorManager.getOrientation(rotationMatrix, orientationAngles)

// Azimuth in radians, convert to degrees (this is magnetic north)
val azimuthRad = orientationAngles[0]
var magneticNorth = Math.toDegrees(azimuthRad.toDouble())

// Normalize to 0-360 range
if (magneticNorth < 0) {
magneticNorth += 360.0
}

// Convert magnetic north to true north using GPS coordinates
val trueNorth = gpsCoordinates?.let { coords ->
val geomagneticField = GeomagneticField(
coords.lat.toFloat(),
coords.lon.toFloat(),
0f,
System.currentTimeMillis()
)
val declination = geomagneticField.declination

var corrected = magneticNorth + declination
// Normalize to 0-360 range
if (corrected < 0) {
corrected += 360.0
} else if (corrected >= 360.0) {
corrected -= 360.0
}

corrected
} ?: magneticNorth // Fall back to magnetic north if GPS not available

trueNorth
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,6 @@ data class HeadwindSettings(
val weatherProvider: WeatherDataProvider = WeatherDataProvider.OPEN_METEO,
val openWeatherMapApiKey: String = "",
val refreshRate: RefreshRate = RefreshRate.STANDARD,
val useMagnetometerForHeading: Boolean = false
){

companion object {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,6 @@ fun SettingsScreen(onFinish: () -> Unit) {
var selectedWeatherProvider by remember { mutableStateOf(WeatherDataProvider.OPEN_METEO) }
var openWeatherMapApiKey by remember { mutableStateOf("") }
var isK2 by remember { mutableStateOf(false) }
var useMagnetometerForHeading by remember { mutableStateOf(false) }

LaunchedEffect(Unit) {
ctx.streamSettings(karooSystem).collect { settings ->
Expand All @@ -87,7 +86,6 @@ fun SettingsScreen(onFinish: () -> Unit) {
selectedWeatherProvider = settings.weatherProvider
openWeatherMapApiKey = settings.openWeatherMapApiKey
refreshRateSetting = settings.refreshRate
useMagnetometerForHeading = settings.useMagnetometerForHeading
}
}

Expand Down Expand Up @@ -116,7 +114,6 @@ fun SettingsScreen(onFinish: () -> Unit) {
weatherProvider = selectedWeatherProvider,
openWeatherMapApiKey = openWeatherMapApiKey,
refreshRate = refreshRateSetting,
useMagnetometerForHeading = useMagnetometerForHeading
)

saveSettings(ctx, newSettings)
Expand Down Expand Up @@ -234,17 +231,6 @@ fun SettingsScreen(onFinish: () -> Unit) {
Text("Show Distance in Forecast")
}

Row(verticalAlignment = Alignment.CenterVertically) {
Switch(checked = useMagnetometerForHeading, onCheckedChange = {
useMagnetometerForHeading = it
coroutineScope.launch {
updateSettings()
}
})
Spacer(modifier = Modifier.width(10.dp))
Text("Use Magnetometer")
}

if (!karooConnected) {
Text(
modifier = Modifier.padding(5.dp),
Expand Down