From 407755e94bfb184b203fb01ac91074646f440a44 Mon Sep 17 00:00:00 2001 From: Tim Kluge Date: Tue, 31 Dec 2024 13:37:38 +0100 Subject: [PATCH 1/5] Show no gps, no weather data error messages in visual data fields --- app/build.gradle.kts | 4 +- app/manifest.json | 6 +- .../de/timklge/karooheadwind/Extensions.kt | 129 ++++++++++++++---- .../karooheadwind/KarooHeadwindExtension.kt | 10 +- .../karooheadwind/datatypes/BaseDataType.kt | 11 +- .../datatypes/HeadwindDirectionDataType.kt | 66 +++++---- .../datatypes/HeadwindSpeedDataType.kt | 9 +- .../datatypes/WeatherDataType.kt | 24 +++- .../datatypes/WeatherForecastDataType.kt | 31 +++-- 9 files changed, 207 insertions(+), 83 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e7506d2..2fe63f2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -15,8 +15,8 @@ android { applicationId = "de.timklge.karooheadwind" minSdk = 26 targetSdk = 35 - versionCode = 6 - versionName = "1.1.2" + versionCode = 7 + versionName = "1.1.3" } signingConfigs { diff --git a/app/manifest.json b/app/manifest.json index 2def8fe..a31a952 100644 --- a/app/manifest.json +++ b/app/manifest.json @@ -3,9 +3,9 @@ "packageName": "de.timklge.karooheadwind", "iconUrl": "https://github.com/timklge/karoo-headwind/releases/latest/download/karoo-headwind.png", "latestApkUrl": "https://github.com/timklge/karoo-headwind/releases/latest/download/app-release.apk", - "latestVersion": "1.1.2", - "latestVersionCode": 6, + "latestVersion": "1.1.3", + "latestVersionCode": 7, "developer": "timklge", "description": "Provides headwind direction, wind speed and other weather data fields", - "releaseNotes": "Add hourly forecast and temperature datafields. Add setting to use absolute wind direction on headwind datafield." + "releaseNotes": "Add hourly forecast and temperature datafields. Show error message in fields if no weather data or gps is available." } \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/Extensions.kt b/app/src/main/kotlin/de/timklge/karooheadwind/Extensions.kt index 0f438ce..a3d5f80 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/Extensions.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/Extensions.kt @@ -2,8 +2,25 @@ package de.timklge.karooheadwind import android.content.Context import android.util.Log +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.TextUnitType +import androidx.compose.ui.unit.dp import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.glance.GlanceModifier +import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi +import androidx.glance.appwidget.GlanceRemoteViews +import androidx.glance.appwidget.RemoteViewsCompositionResult +import androidx.glance.color.ColorProvider +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.padding +import androidx.glance.text.Text +import androidx.glance.text.TextAlign +import androidx.glance.text.TextStyle import de.timklge.karooheadwind.datatypes.GpsCoordinates import de.timklge.karooheadwind.screens.HeadwindSettings import de.timklge.karooheadwind.screens.HeadwindStats @@ -28,6 +45,7 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNot import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf @@ -86,7 +104,30 @@ fun KarooSystemService.streamDataFlow(dataTypeId: String): Flow { } } -fun Context.streamCurrentWeatherData(): Flow { +@OptIn(ExperimentalGlanceRemoteViewsApi::class) +suspend fun getErrorWidget(glance: GlanceRemoteViews, context: Context, settings: HeadwindSettings?, headingResponse: HeadingResponse?): RemoteViewsCompositionResult { + return glance.compose(context, DpSize.Unspecified) { + Box(modifier = GlanceModifier.fillMaxSize().padding(5.dp), contentAlignment = Alignment.Center) { + val errorMessage = if (settings?.welcomeDialogAccepted == false) { + "Headwind app not set up" + } else if (headingResponse is HeadingResponse.NoGps){ + "No GPS signal" + } else if (headingResponse is HeadingResponse.NoWeatherData) { + "No weather data" + } else { + "Unknown error" + } + + Log.d(KarooHeadwindExtension.TAG, "Error widget: $errorMessage") + + Text(text = errorMessage, style = TextStyle(fontSize = TextUnit(16f, TextUnitType.Sp), + textAlign = TextAlign.Center, + color = ColorProvider(Color.Black, Color.White))) + } + } +} + +fun Context.streamCurrentWeatherData(): Flow { return dataStore.data.map { settingsJson -> try { val data = settingsJson[currentDataKey] @@ -95,7 +136,13 @@ fun Context.streamCurrentWeatherData(): Flow { Log.e(KarooHeadwindExtension.TAG, "Failed to read weather data", e) null } - }.filterNotNull().distinctUntilChanged().filter { it.current.time * 1000 >= System.currentTimeMillis() - (1000 * 60 * 60 * 12) } + }.distinctUntilChanged().map { response -> + if (response != null && response.current.time * 1000 >= System.currentTimeMillis() - (1000 * 60 * 60 * 12)){ + response + } else { + null + } + } } fun Context.streamWidgetSettings(): Flow { @@ -212,63 +259,95 @@ fun signedAngleDifference(angle1: Double, angle2: Double): Double { return sign * diff } -fun KarooSystemService.getRelativeHeadingFlow(context: Context): Flow { +sealed class HeadingResponse { + data object NoGps: HeadingResponse() + data object NoWeatherData: HeadingResponse() + data class Value(val diff: Double): HeadingResponse() +} + +fun KarooSystemService.getRelativeHeadingFlow(context: Context): Flow { val currentWeatherData = context.streamCurrentWeatherData() return getHeadingFlow() - .filter { it >= 0 } .combine(currentWeatherData) { bearing, data -> bearing to data } .map { (bearing, data) -> - val windBearing = data.current.windDirection + 180 - val diff = signedAngleDifference(bearing, windBearing) - Log.d(KarooHeadwindExtension.TAG, "Wind bearing: $bearing vs $windBearing => $diff") + when { + bearing is HeadingResponse.Value && data != null -> { + val windBearing = data.current.windDirection + 180 + val diff = signedAngleDifference(bearing.diff, windBearing) - diff + Log.d(KarooHeadwindExtension.TAG, "Wind bearing: $bearing vs $windBearing => $diff") + + HeadingResponse.Value(diff) + } + bearing is HeadingResponse.NoGps -> HeadingResponse.NoGps + bearing is HeadingResponse.NoWeatherData || data == null -> HeadingResponse.NoWeatherData + else -> bearing + } } } -fun KarooSystemService.getHeadingFlow(): Flow { - // return flowOf(20.0) +fun KarooSystemService.getHeadingFlow(): Flow { + // return flowOf(HeadingResponse.Value(20.0)) return streamDataFlow(DataType.Type.LOCATION) - .mapNotNull { (it as? StreamState.Streaming)?.dataPoint?.values } + .map { (it as? StreamState.Streaming)?.dataPoint?.values } .map { values -> - val heading = values[DataType.Field.LOC_BEARING] + val heading = values?.get(DataType.Field.LOC_BEARING) Log.d(KarooHeadwindExtension.TAG, "Updated gps bearing: $heading") - heading ?: 0.0 + val headingValue = heading?.let { HeadingResponse.Value(it) } + + headingValue ?: HeadingResponse.NoGps } .distinctUntilChanged() - .scan(emptyList()) { acc, value -> /* Average over 3 values */ + .scan(emptyList()) { acc, value -> /* Average over 3 values */ + if (value !is HeadingResponse.Value) return@scan listOf(value) + val newAcc = acc + value if (newAcc.size > 3) newAcc.drop(1) else newAcc } - .map { it.average() } + .map { data -> + if (data.isEmpty()) return@map HeadingResponse.NoGps + + if (data.all { it is HeadingResponse.Value }) { + val avg = data.mapNotNull { (it as? HeadingResponse.Value)?.diff }.average() + HeadingResponse.Value(avg) + } else { + data.first() + } + } } @OptIn(FlowPreview::class) -fun KarooSystemService.getGpsCoordinateFlow(context: Context): Flow { +fun KarooSystemService.getGpsCoordinateFlow(context: Context): Flow { // return flowOf(GpsCoordinates(52.5164069,13.3784)) return streamDataFlow(DataType.Type.LOCATION) - .mapNotNull { (it as? StreamState.Streaming)?.dataPoint?.values } - .mapNotNull { values -> - val lat = values[DataType.Field.LOC_LATITUDE] - val lon = values[DataType.Field.LOC_LONGITUDE] + .map { (it as? StreamState.Streaming)?.dataPoint?.values } + .map { values -> + val lat = values?.get(DataType.Field.LOC_LATITUDE) + val lon = values?.get(DataType.Field.LOC_LONGITUDE) if (lat != null && lon != null){ - Log.d(KarooHeadwindExtension.TAG, "Updated gps coords: $lat $lon") + Log.d(KarooHeadwindExtension.TAG, "Updated gps coordinates: $lat $lon") GpsCoordinates(lat, lon) } else { - Log.e(KarooHeadwindExtension.TAG, "Missing gps values: $values") + Log.w(KarooHeadwindExtension.TAG, "Gps unavailable: $values") null } } .combine(context.streamSettings(this)) { gps, settings -> gps to settings } .map { (gps, settings) -> - val rounded = gps.round(settings.roundLocationTo.km.toDouble()) - Log.d(KarooHeadwindExtension.TAG, "Round location to ${settings.roundLocationTo.km} - $rounded") + val rounded = gps?.round(settings.roundLocationTo.km.toDouble()) + if (rounded != null) Log.d(KarooHeadwindExtension.TAG, "Round location to ${settings.roundLocationTo.km} - $rounded") rounded } - .distinctUntilChanged { old, new -> old.distanceTo(new).absoluteValue < 0.001 } + .distinctUntilChanged { old, new -> + if (old != null && new != null) { + old.distanceTo(new).absoluteValue < 0.001 + } else { + old == new + } + } .debounce(Duration.ofSeconds(10)) } \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/KarooHeadwindExtension.kt b/app/src/main/kotlin/de/timklge/karooheadwind/KarooHeadwindExtension.kt index b37a53e..dc029cd 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/KarooHeadwindExtension.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/KarooHeadwindExtension.kt @@ -35,7 +35,7 @@ import kotlinx.coroutines.launch import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes -class KarooHeadwindExtension : KarooExtension("karoo-headwind", "1.1.2") { +class KarooHeadwindExtension : KarooExtension("karoo-headwind", "1.1.3") { companion object { const val TAG = "karoo-headwind" } @@ -62,7 +62,7 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", "1.1.2") { ) } - data class StreamData(val settings: HeadwindSettings, val gps: GpsCoordinates, + data class StreamData(val settings: HeadwindSettings, val gps: GpsCoordinates?, val profile: UserProfile? = null) @OptIn(ExperimentalCoroutinesApi::class) @@ -80,7 +80,7 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", "1.1.2") { val gpsFlow = karooSystem .getGpsCoordinateFlow(this@KarooHeadwindExtension) - .transformLatest { value: GpsCoordinates -> + .transformLatest { value: GpsCoordinates? -> while(true){ emit(value) delay(1.hours) @@ -101,6 +101,10 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", "1.1.2") { HeadwindStats() } + if (gps == null){ + error("No GPS coordinates available") + } + val response = karooSystem.makeOpenMeteoHttpRequest(gps, settings, profile) if (response.error != null){ try { diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/BaseDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/BaseDataType.kt index e59a2aa..ce93f2a 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/BaseDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/BaseDataType.kt @@ -12,6 +12,7 @@ import io.hammerhead.karooext.models.DataType import io.hammerhead.karooext.models.StreamState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch abstract class BaseDataType( @@ -25,10 +26,12 @@ abstract class BaseDataType( val job = CoroutineScope(Dispatchers.IO).launch { val currentWeatherData = applicationContext.streamCurrentWeatherData() - currentWeatherData.collect { data -> - val value = getValue(data) - Log.d(KarooHeadwindExtension.TAG, "$dataTypeId: $value") - emitter.onNext(StreamState.Streaming(DataPoint(dataTypeId, mapOf(DataType.Field.SINGLE to value)))) + currentWeatherData + .filterNotNull() + .collect { data -> + val value = getValue(data) + Log.d(KarooHeadwindExtension.TAG, "$dataTypeId: $value") + emitter.onNext(StreamState.Streaming(DataPoint(dataTypeId, mapOf(DataType.Field.SINGLE to value)))) } } emitter.setCancellable { diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindDirectionDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindDirectionDataType.kt index 832d154..ee14750 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindDirectionDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindDirectionDataType.kt @@ -6,7 +6,9 @@ import android.util.Log import androidx.compose.ui.unit.DpSize import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi import androidx.glance.appwidget.GlanceRemoteViews +import de.timklge.karooheadwind.HeadingResponse import de.timklge.karooheadwind.KarooHeadwindExtension +import de.timklge.karooheadwind.getErrorWidget import de.timklge.karooheadwind.getRelativeHeadingFlow import de.timklge.karooheadwind.screens.HeadwindSettings import de.timklge.karooheadwind.screens.WindDirectionIndicatorSetting @@ -47,7 +49,8 @@ class HeadwindDirectionDataType( val job = CoroutineScope(Dispatchers.IO).launch { karooSystem.getRelativeHeadingFlow(applicationContext) .collect { diff -> - emitter.onNext(StreamState.Streaming(DataPoint(dataTypeId, mapOf(DataType.Field.SINGLE to diff)))) + val value = (diff as? HeadingResponse.Value)?.diff ?: 0.0 + emitter.onNext(StreamState.Streaming(DataPoint(dataTypeId, mapOf(DataType.Field.SINGLE to value)))) } } emitter.setCancellable { @@ -55,7 +58,7 @@ class HeadwindDirectionDataType( } } - data class StreamData(val value: Double, val absoluteWindDirection: Double, val windSpeed: Double, val settings: HeadwindSettings) + data class StreamData(val headingResponse: HeadingResponse?, val absoluteWindDirection: Double?, val windSpeed: Double?, val settings: HeadwindSettings? = null) private fun previewFlow(): Flow { return flow { @@ -63,7 +66,7 @@ class HeadwindDirectionDataType( val bearing = (0..360).random().toDouble() val windSpeed = (-20..20).random() - emit(StreamData(bearing, bearing, windSpeed.toDouble(), HeadwindSettings())) + emit(StreamData(HeadingResponse.Value(bearing), bearing, windSpeed.toDouble(), HeadwindSettings())) delay(2_000) } } @@ -86,36 +89,47 @@ class HeadwindDirectionDataType( val flow = if (config.preview) { previewFlow() } else { - karooSystem.streamDataFlow(dataTypeId) - .mapNotNull { (it as? StreamState.Streaming)?.dataPoint?.singleValue } - .combine(context.streamCurrentWeatherData()) { value, data -> value to data } - .combine(context.streamSettings(karooSystem)) { (value, data), settings -> - StreamData(value, data.current.windDirection, data.current.windSpeed, settings) - } + karooSystem.getRelativeHeadingFlow(context) + .combine(context.streamCurrentWeatherData()) { headingResponse, data -> StreamData(headingResponse, data?.current?.windDirection, data?.current?.windSpeed) } + .combine(context.streamSettings(karooSystem)) { data, settings -> data.copy(settings = settings) } } val viewJob = CoroutineScope(Dispatchers.IO).launch { flow.collect { streamData -> - Log.d(KarooHeadwindExtension.TAG, "Updating headwind direction view") - val windSpeed = streamData.windSpeed - val windDirection = when (streamData.settings.windDirectionIndicatorSetting){ - WindDirectionIndicatorSetting.HEADWIND_DIRECTION -> streamData.value - WindDirectionIndicatorSetting.WIND_DIRECTION -> streamData.absoluteWindDirection + 180 - } - val text = when (streamData.settings.windDirectionIndicatorTextSetting) { - WindDirectionIndicatorTextSetting.HEADWIND_SPEED -> { - val headwindSpeed = cos( (windDirection + 180) * Math.PI / 180.0) * windSpeed - headwindSpeed.roundToInt().toString() - } - WindDirectionIndicatorTextSetting.WIND_SPEED -> windSpeed.roundToInt().toString() - WindDirectionIndicatorTextSetting.NONE -> "" + Log.d(KarooHeadwindExtension.TAG, "Updating headwind direction view") + + val value = (streamData.headingResponse as? HeadingResponse.Value)?.diff + if (value == null || streamData.absoluteWindDirection == null || streamData.settings == null || streamData.windSpeed == null){ + var headingResponse = streamData.headingResponse + + if (headingResponse is HeadingResponse.Value && (streamData.absoluteWindDirection == null || streamData.windSpeed == null)){ + headingResponse = HeadingResponse.NoWeatherData } - val result = glance.compose(context, DpSize.Unspecified) { - HeadwindDirection(baseBitmap, windDirection.roundToInt(), config.textSize, text) - } + emitter.updateView(getErrorWidget(glance, context, streamData.settings, headingResponse).remoteViews) - emitter.updateView(result.remoteViews) + return@collect + } + + val windSpeed = streamData.windSpeed + val windDirection = when (streamData.settings.windDirectionIndicatorSetting){ + WindDirectionIndicatorSetting.HEADWIND_DIRECTION -> value + WindDirectionIndicatorSetting.WIND_DIRECTION -> streamData.absoluteWindDirection + 180 + } + val text = when (streamData.settings.windDirectionIndicatorTextSetting) { + WindDirectionIndicatorTextSetting.HEADWIND_SPEED -> { + val headwindSpeed = cos( (windDirection + 180) * Math.PI / 180.0) * windSpeed + headwindSpeed.roundToInt().toString() + } + WindDirectionIndicatorTextSetting.WIND_SPEED -> windSpeed.roundToInt().toString() + WindDirectionIndicatorTextSetting.NONE -> "" + } + + val result = glance.compose(context, DpSize.Unspecified) { + HeadwindDirection(baseBitmap, windDirection.roundToInt(), config.textSize, text) + } + + emitter.updateView(result.remoteViews) } } emitter.setCancellable { diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindSpeedDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindSpeedDataType.kt index 2af4a6e..f34a370 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindSpeedDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindSpeedDataType.kt @@ -1,6 +1,7 @@ package de.timklge.karooheadwind.datatypes import android.content.Context +import de.timklge.karooheadwind.HeadingResponse import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse import de.timklge.karooheadwind.getRelativeHeadingFlow import de.timklge.karooheadwind.screens.HeadwindSettings @@ -15,6 +16,7 @@ import io.hammerhead.karooext.models.StreamState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch import kotlin.math.cos @@ -22,16 +24,17 @@ class HeadwindSpeedDataType( private val karooSystem: KarooSystemService, private val context: Context) : DataTypeImpl("karoo-headwind", "headwindSpeed"){ - data class StreamData(val value: Double, val data: OpenMeteoCurrentWeatherResponse, val settings: HeadwindSettings) + data class StreamData(val headingResponse: HeadingResponse, val weatherResponse: OpenMeteoCurrentWeatherResponse?, val settings: HeadwindSettings) override fun startStream(emitter: Emitter) { val job = CoroutineScope(Dispatchers.IO).launch { karooSystem.getRelativeHeadingFlow(context) .combine(context.streamCurrentWeatherData()) { value, data -> value to data } .combine(context.streamSettings(karooSystem)) { (value, data), settings -> StreamData(value, data, settings) } + .filter { it.weatherResponse != null } .collect { streamData -> - val windSpeed = streamData.data.current.windSpeed - val windDirection = streamData.value + val windSpeed = streamData.weatherResponse?.current?.windSpeed ?: 0.0 + val windDirection = (streamData.headingResponse as? HeadingResponse.Value)?.diff ?: 0.0 val headwindSpeed = cos( (windDirection + 180) * Math.PI / 180.0) * windSpeed emitter.onNext(StreamState.Streaming(DataPoint(dataTypeId, mapOf(DataType.Field.SINGLE to headwindSpeed)))) diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherDataType.kt index 7b927b0..5d735be 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherDataType.kt @@ -4,15 +4,22 @@ import android.content.Context import android.graphics.BitmapFactory import android.util.Log import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.TextUnitType import androidx.glance.GlanceModifier import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi import androidx.glance.appwidget.GlanceRemoteViews import androidx.glance.layout.Alignment import androidx.glance.layout.Box import androidx.glance.layout.fillMaxSize +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import de.timklge.karooheadwind.HeadingResponse import de.timklge.karooheadwind.KarooHeadwindExtension import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse import de.timklge.karooheadwind.WeatherInterpretation +import de.timklge.karooheadwind.getErrorWidget +import de.timklge.karooheadwind.getHeadingFlow import de.timklge.karooheadwind.screens.HeadwindSettings import de.timklge.karooheadwind.screens.PrecipitationUnit import de.timklge.karooheadwind.screens.TemperatureUnit @@ -33,6 +40,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filterNot +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.launch import java.time.Instant @@ -57,6 +66,7 @@ class WeatherDataType( val currentWeatherData = applicationContext.streamCurrentWeatherData() currentWeatherData + .filterNotNull() .collect { data -> Log.d(KarooHeadwindExtension.TAG, "Wind code: ${data.current.weatherCode}") emitter.onNext(StreamState.Streaming(DataPoint(dataTypeId, mapOf(DataType.Field.SINGLE to data.current.weatherCode.toDouble())))) @@ -79,15 +89,23 @@ class WeatherDataType( de.timklge.karooheadwind.R.drawable.arrow_0 ) - data class StreamData(val data: OpenMeteoCurrentWeatherResponse, val settings: HeadwindSettings, - val profile: UserProfile? = null) + data class StreamData(val data: OpenMeteoCurrentWeatherResponse?, val settings: HeadwindSettings, + val profile: UserProfile? = null, val headingResponse: HeadingResponse? = null) val viewJob = CoroutineScope(Dispatchers.IO).launch { context.streamCurrentWeatherData() .combine(context.streamSettings(karooSystem)) { data, settings -> StreamData(data, settings) } .combine(karooSystem.streamUserProfile()) { data, profile -> data.copy(profile = profile) } - .collect { (data, settings, userProfile) -> + .combine(karooSystem.getHeadingFlow()) { data, heading -> data.copy(headingResponse = heading) } + .collect { (data, settings, userProfile, headingResponse) -> Log.d(KarooHeadwindExtension.TAG, "Updating weather view") + + if (data == null){ + emitter.updateView(getErrorWidget(glance, context, settings, headingResponse).remoteViews) + + return@collect + } + val interpretation = WeatherInterpretation.fromWeatherCode(data.current.weatherCode) val formattedTime = timeFormatter.format(Instant.ofEpochSecond(data.current.time)) diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherForecastDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherForecastDataType.kt index f30416b..e14668a 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherForecastDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherForecastDataType.kt @@ -3,10 +3,6 @@ package de.timklge.karooheadwind.datatypes import android.content.Context import android.graphics.BitmapFactory import android.util.Log -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp @@ -25,12 +21,13 @@ import androidx.glance.layout.Row import androidx.glance.layout.Spacer import androidx.glance.layout.fillMaxHeight import androidx.glance.layout.fillMaxSize -import androidx.glance.layout.padding import androidx.glance.layout.width +import de.timklge.karooheadwind.HeadingResponse import de.timklge.karooheadwind.KarooHeadwindExtension import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse import de.timklge.karooheadwind.WeatherInterpretation -import de.timklge.karooheadwind.saveSettings +import de.timklge.karooheadwind.getErrorWidget +import de.timklge.karooheadwind.getHeadingFlow import de.timklge.karooheadwind.saveWidgetSettings import de.timklge.karooheadwind.screens.HeadwindSettings import de.timklge.karooheadwind.screens.HeadwindWidgetSettings @@ -55,7 +52,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.launch import java.time.Instant import java.time.ZoneId @@ -74,7 +70,7 @@ class CycleHoursAction : ActionCallback { val data = context.streamCurrentWeatherData().first() var hourOffset = currentSettings.currentForecastHourOffset + 3 - if (hourOffset >= data.forecastData.weatherCode.size) { + if (data == null || hourOffset >= data.forecastData.weatherCode.size) { hourOffset = 0 } @@ -101,8 +97,8 @@ class WeatherForecastDataType( currentWeatherData .collect { data -> - Log.d(KarooHeadwindExtension.TAG, "Wind code: ${data.current.weatherCode}") - emitter.onNext(StreamState.Streaming(DataPoint(dataTypeId, mapOf(DataType.Field.SINGLE to data.current.weatherCode.toDouble())))) + Log.d(KarooHeadwindExtension.TAG, "Wind code: ${data?.current?.weatherCode}") + emitter.onNext(StreamState.Streaming(DataPoint(dataTypeId, mapOf(DataType.Field.SINGLE to (data?.current?.weatherCode?.toDouble() ?: 0.0))))) } } emitter.setCancellable { @@ -122,16 +118,23 @@ class WeatherForecastDataType( de.timklge.karooheadwind.R.drawable.arrow_0 ) - data class StreamData(val data: OpenMeteoCurrentWeatherResponse, val settings: HeadwindSettings, - val widgetSettings: HeadwindWidgetSettings? = null, val profile: UserProfile? = null) + data class StreamData(val data: OpenMeteoCurrentWeatherResponse?, val settings: HeadwindSettings, + val widgetSettings: HeadwindWidgetSettings? = null, val profile: UserProfile? = null, val headingResponse: HeadingResponse? = null) val viewJob = CoroutineScope(Dispatchers.IO).launch { context.streamCurrentWeatherData() .combine(context.streamSettings(karooSystem)) { data, settings -> StreamData(data, settings) } .combine(karooSystem.streamUserProfile()) { data, profile -> data.copy(profile = profile) } .combine(context.streamWidgetSettings()) { data, widgetSettings -> data.copy(widgetSettings = widgetSettings) } - .collect { (data, settings, widgetSettings, userProfile) -> - Log.d(KarooHeadwindExtension.TAG, "Updating weather view") + .combine(karooSystem.getHeadingFlow()) { data, headingResponse -> data.copy(headingResponse = headingResponse) } + .collect { (data, settings, widgetSettings, userProfile, headingResponse) -> + Log.d(KarooHeadwindExtension.TAG, "Updating weather forecast view") + + if (data == null){ + emitter.updateView(getErrorWidget(glance, context, settings, headingResponse).remoteViews) + + return@collect + } val result = glance.compose(context, DpSize.Unspecified) { Row(modifier = GlanceModifier.fillMaxSize().clickable(onClick = actionRunCallback()), horizontalAlignment = Alignment.Horizontal.CenterHorizontally) { From a662cc67579ed9cb89437c787ab1852043a9554c Mon Sep 17 00:00:00 2001 From: Tim Kluge Date: Fri, 3 Jan 2025 00:18:42 +0100 Subject: [PATCH 2/5] fix #21: Save last known gps position --- .../de/timklge/karooheadwind/Extensions.kt | 79 ++++++++++++++++--- .../karooheadwind/KarooHeadwindExtension.kt | 6 ++ .../de/timklge/karooheadwind/Throttle.kt | 16 ++++ .../karooheadwind/datatypes/GpsCoordinates.kt | 2 +- .../datatypes/WeatherDataType.kt | 7 +- .../datatypes/WeatherForecastDataType.kt | 2 +- 6 files changed, 96 insertions(+), 16 deletions(-) create mode 100644 app/src/main/kotlin/de/timklge/karooheadwind/Throttle.kt diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/Extensions.kt b/app/src/main/kotlin/de/timklge/karooheadwind/Extensions.kt index a3d5f80..0153f5d 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/Extensions.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/Extensions.kt @@ -48,9 +48,8 @@ import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterNot import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.scan import kotlinx.coroutines.flow.single import kotlinx.coroutines.flow.timeout @@ -68,6 +67,7 @@ val settingsKey = stringPreferencesKey("settings") val widgetSettingsKey = stringPreferencesKey("widgetSettings") val currentDataKey = stringPreferencesKey("current") val statsKey = stringPreferencesKey("stats") +val lastKnownPositionKey = stringPreferencesKey("lastKnownPosition") suspend fun saveSettings(context: Context, settings: HeadwindSettings) { context.dataStore.edit { t -> @@ -93,6 +93,18 @@ suspend fun saveCurrentData(context: Context, forecast: OpenMeteoCurrentWeatherR } } +suspend fun saveLastKnownPosition(context: Context, gpsCoordinates: GpsCoordinates) { + Log.i(KarooHeadwindExtension.TAG, "Saving last known position: $gpsCoordinates") + + try { + context.dataStore.edit { t -> + t[lastKnownPositionKey] = Json.encodeToString(gpsCoordinates) + } + } catch(e: Throwable){ + Log.e(KarooHeadwindExtension.TAG, "Failed to save last known position", e) + } +} + fun KarooSystemService.streamDataFlow(dataTypeId: String): Flow { return callbackFlow { val listenerId = addConsumer(OnStreamState.StartStreaming(dataTypeId)) { event: OnStreamState -> @@ -194,6 +206,22 @@ fun Context.streamStats(): Flow { }.distinctUntilChanged() } +suspend fun Context.getLastKnownPosition(): GpsCoordinates? { + val settingsJson = dataStore.data.first() + + try { + val lastKnownPositionString = settingsJson[lastKnownPositionKey] ?: return null + val lastKnownPosition = jsonWithUnknownKeys.decodeFromString( + lastKnownPositionString + ) + + return lastKnownPosition + } catch(e: Throwable){ + Log.e(KarooHeadwindExtension.TAG, "Failed to read last known position", e) + return null + } +} + fun KarooSystemService.streamUserProfile(): Flow { return callbackFlow { val listenerId = addConsumer { userProfile: UserProfile -> @@ -268,7 +296,7 @@ sealed class HeadingResponse { fun KarooSystemService.getRelativeHeadingFlow(context: Context): Flow { val currentWeatherData = context.streamCurrentWeatherData() - return getHeadingFlow() + return getHeadingFlow(context) .combine(currentWeatherData) { bearing, data -> bearing to data } .map { (bearing, data) -> when { @@ -287,13 +315,12 @@ fun KarooSystemService.getRelativeHeadingFlow(context: Context): Flow { +fun KarooSystemService.getHeadingFlow(context: Context): Flow { // return flowOf(HeadingResponse.Value(20.0)) - return streamDataFlow(DataType.Type.LOCATION) - .map { (it as? StreamState.Streaming)?.dataPoint?.values } - .map { values -> - val heading = values?.get(DataType.Field.LOC_BEARING) + return getGpsCoordinateFlow(context) + .map { coords -> + val heading = coords?.bearing Log.d(KarooHeadwindExtension.TAG, "Updated gps bearing: $heading") val headingValue = heading?.let { HeadingResponse.Value(it) } @@ -318,24 +345,56 @@ fun KarooSystemService.getHeadingFlow(): Flow { } } +fun concatenate(vararg flows: Flow) = flow { + var hadNullValue = false + + for (flow in flows) { + flow.collect { value -> + if (!hadNullValue) { + emit(value) + if (value == null) hadNullValue = true + } else { + if (value != null) emit(value) + } + } + } +} + +@OptIn(FlowPreview::class) +suspend fun KarooSystemService.updateLastKnownGps(context: Context) { + getGpsCoordinateFlow(context) + .filterNotNull() + .throttle(60 * 1_000) // Only update last known gps position once every minute + .collect { gps -> + saveLastKnownPosition(context, gps) + } +} + @OptIn(FlowPreview::class) fun KarooSystemService.getGpsCoordinateFlow(context: Context): Flow { // return flowOf(GpsCoordinates(52.5164069,13.3784)) - return streamDataFlow(DataType.Type.LOCATION) + val initialFlow = flow { context.getLastKnownPosition() } + + val gpsFlow = streamDataFlow(DataType.Type.LOCATION) .map { (it as? StreamState.Streaming)?.dataPoint?.values } .map { values -> val lat = values?.get(DataType.Field.LOC_LATITUDE) val lon = values?.get(DataType.Field.LOC_LONGITUDE) + val bearing = values?.get(DataType.Field.LOC_BEARING) if (lat != null && lon != null){ Log.d(KarooHeadwindExtension.TAG, "Updated gps coordinates: $lat $lon") - GpsCoordinates(lat, lon) + GpsCoordinates(lat, lon, bearing) } else { Log.w(KarooHeadwindExtension.TAG, "Gps unavailable: $values") null } } + + val concatenatedFlow = concatenate(initialFlow, gpsFlow) + + return concatenatedFlow .combine(context.streamSettings(this)) { gps, settings -> gps to settings } .map { (gps, settings) -> val rounded = gps?.round(settings.roundLocationTo.km.toDouble()) diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/KarooHeadwindExtension.kt b/app/src/main/kotlin/de/timklge/karooheadwind/KarooHeadwindExtension.kt index dc029cd..95af250 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/KarooHeadwindExtension.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/KarooHeadwindExtension.kt @@ -40,8 +40,10 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", "1.1.3") { const val TAG = "karoo-headwind" } + lateinit var karooSystem: KarooSystemService + private var updateLastKnownGpsJob: Job? = null private var serviceJob: Job? = null override val types by lazy { @@ -71,6 +73,10 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", "1.1.3") { karooSystem = KarooSystemService(applicationContext) + updateLastKnownGpsJob = CoroutineScope(Dispatchers.IO).launch { + karooSystem.updateLastKnownGps(this@KarooHeadwindExtension) + } + serviceJob = CoroutineScope(Dispatchers.IO).launch { karooSystem.connect { connected -> if (connected) { diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/Throttle.kt b/app/src/main/kotlin/de/timklge/karooheadwind/Throttle.kt new file mode 100644 index 0000000..1fd88bc --- /dev/null +++ b/app/src/main/kotlin/de/timklge/karooheadwind/Throttle.kt @@ -0,0 +1,16 @@ +package de.timklge.karooheadwind + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +fun Flow.throttle(timeout: Long): Flow = flow { + var lastEmissionTime = 0L + + collect { value -> + val currentTime = System.currentTimeMillis() + if (currentTime - lastEmissionTime >= timeout) { + emit(value) + lastEmissionTime = currentTime + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/GpsCoordinates.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/GpsCoordinates.kt index bcf2bc4..eb1a5c2 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/GpsCoordinates.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/GpsCoordinates.kt @@ -9,7 +9,7 @@ import kotlin.math.sin import kotlin.math.sqrt @Serializable -data class GpsCoordinates(val lat: Double, val lon: Double){ +data class GpsCoordinates(val lat: Double, val lon: Double, val bearing: Double? = 0.0){ companion object { private fun roundDegrees(degrees: Double, km: Double): Double { val nkm = degrees * 111 diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherDataType.kt index 5d735be..1d33083 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherDataType.kt @@ -66,10 +66,9 @@ class WeatherDataType( val currentWeatherData = applicationContext.streamCurrentWeatherData() currentWeatherData - .filterNotNull() .collect { data -> - Log.d(KarooHeadwindExtension.TAG, "Wind code: ${data.current.weatherCode}") - emitter.onNext(StreamState.Streaming(DataPoint(dataTypeId, mapOf(DataType.Field.SINGLE to data.current.weatherCode.toDouble())))) + Log.d(KarooHeadwindExtension.TAG, "Wind code: ${data?.current?.weatherCode}") + emitter.onNext(StreamState.Streaming(DataPoint(dataTypeId, mapOf(DataType.Field.SINGLE to (data?.current?.weatherCode?.toDouble() ?: 0.0))))) } } emitter.setCancellable { @@ -96,7 +95,7 @@ class WeatherDataType( context.streamCurrentWeatherData() .combine(context.streamSettings(karooSystem)) { data, settings -> StreamData(data, settings) } .combine(karooSystem.streamUserProfile()) { data, profile -> data.copy(profile = profile) } - .combine(karooSystem.getHeadingFlow()) { data, heading -> data.copy(headingResponse = heading) } + .combine(karooSystem.getHeadingFlow(context)) { data, heading -> data.copy(headingResponse = heading) } .collect { (data, settings, userProfile, headingResponse) -> Log.d(KarooHeadwindExtension.TAG, "Updating weather view") diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherForecastDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherForecastDataType.kt index e14668a..cd1e582 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherForecastDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherForecastDataType.kt @@ -126,7 +126,7 @@ class WeatherForecastDataType( .combine(context.streamSettings(karooSystem)) { data, settings -> StreamData(data, settings) } .combine(karooSystem.streamUserProfile()) { data, profile -> data.copy(profile = profile) } .combine(context.streamWidgetSettings()) { data, widgetSettings -> data.copy(widgetSettings = widgetSettings) } - .combine(karooSystem.getHeadingFlow()) { data, headingResponse -> data.copy(headingResponse = headingResponse) } + .combine(karooSystem.getHeadingFlow(context)) { data, headingResponse -> data.copy(headingResponse = headingResponse) } .collect { (data, settings, widgetSettings, userProfile, headingResponse) -> Log.d(KarooHeadwindExtension.TAG, "Updating weather forecast view") From d3109e459cafe31e7680c0918914614f405e1e3d Mon Sep 17 00:00:00 2001 From: Tim Kluge Date: Fri, 3 Jan 2025 00:19:26 +0100 Subject: [PATCH 3/5] Update icon --- app/src/main/AndroidManifest.xml | 4 ++-- app/src/main/ic_launcher-playstore.png | Bin 21473 -> 0 bytes app/src/main/res/drawable/launch_background.xml | 6 ++++++ app/src/main/res/drawable/wind.png | Bin 0 -> 11051 bytes .../main/res/mipmap-anydpi-v26/ic_launcher.xml | 5 ----- .../res/mipmap-anydpi-v26/ic_launcher_round.xml | 5 ----- .../main/res/values/ic_launcher_background.xml | 4 ---- app/src/main/res/values/styles.xml | 6 ++++++ app/src/main/res/xml/extension_info.xml | 16 ++++++++-------- 9 files changed, 22 insertions(+), 24 deletions(-) delete mode 100644 app/src/main/ic_launcher-playstore.png create mode 100644 app/src/main/res/drawable/launch_background.xml create mode 100644 app/src/main/res/drawable/wind.png delete mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml delete mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml delete mode 100644 app/src/main/res/values/ic_launcher_background.xml create mode 100644 app/src/main/res/values/styles.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9e22dc0..15236d9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,13 +4,13 @@ diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png deleted file mode 100644 index 644d13983b6f5358e127ae39b2d6ca27c3e8d768..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 21473 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 diff --git a/app/src/main/res/drawable/launch_background.xml b/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..ce52b2c --- /dev/null +++ b/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/wind.png b/app/src/main/res/drawable/wind.png new file mode 100644 index 0000000000000000000000000000000000000000..9461f5be2072465f20448d63b3226660bb8081ff GIT binary patch literal 11051 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 - - - - \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml deleted file mode 100644 index 036d09b..0000000 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml deleted file mode 100644 index beab31f..0000000 --- a/app/src/main/res/values/ic_launcher_background.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - #000000 - \ No newline at end of file diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..285301a --- /dev/null +++ b/app/src/main/res/values/styles.xml @@ -0,0 +1,6 @@ + + + + \ 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 4694ea5..f8cf6a1 100644 --- a/app/src/main/res/xml/extension_info.xml +++ b/app/src/main/res/xml/extension_info.xml @@ -1,35 +1,35 @@ Date: Fri, 3 Jan 2025 02:02:43 +0100 Subject: [PATCH 4/5] Fix gps flow reset --- app/manifest.json | 2 +- .../de/timklge/karooheadwind/Extensions.kt | 87 ++++++++++--------- .../karooheadwind/KarooHeadwindExtension.kt | 15 +++- 3 files changed, 60 insertions(+), 44 deletions(-) diff --git a/app/manifest.json b/app/manifest.json index a31a952..aa1632a 100644 --- a/app/manifest.json +++ b/app/manifest.json @@ -7,5 +7,5 @@ "latestVersionCode": 7, "developer": "timklge", "description": "Provides headwind direction, wind speed and other weather data fields", - "releaseNotes": "Add hourly forecast and temperature datafields. Show error message in fields if no weather data or gps is available." + "releaseNotes": "Adds hourly forecast. Shows error message in fields if weather data or gps are unavailable and remembers last known gps position." } \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/Extensions.kt b/app/src/main/kotlin/de/timklge/karooheadwind/Extensions.kt index 0153f5d..75cdffa 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/Extensions.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/Extensions.kt @@ -39,13 +39,13 @@ import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.channels.trySendBlocking +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.filterNot +import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow @@ -124,10 +124,8 @@ suspend fun getErrorWidget(glance: GlanceRemoteViews, context: Context, settings "Headwind app not set up" } else if (headingResponse is HeadingResponse.NoGps){ "No GPS signal" - } else if (headingResponse is HeadingResponse.NoWeatherData) { - "No weather data" } else { - "Unknown error" + "Weather data download failed" } Log.d(KarooHeadwindExtension.TAG, "Error widget: $errorMessage") @@ -207,7 +205,7 @@ fun Context.streamStats(): Flow { } suspend fun Context.getLastKnownPosition(): GpsCoordinates? { - val settingsJson = dataStore.data.first() + val settingsJson = dataStore.data.first() try { val lastKnownPositionString = settingsJson[lastKnownPositionKey] ?: return null @@ -334,47 +332,61 @@ fun KarooSystemService.getHeadingFlow(context: Context): Flow { if (newAcc.size > 3) newAcc.drop(1) else newAcc } .map { data -> - if (data.isEmpty()) return@map HeadingResponse.NoGps + Log.i(KarooHeadwindExtension.TAG, "Heading value: $data") - if (data.all { it is HeadingResponse.Value }) { - val avg = data.mapNotNull { (it as? HeadingResponse.Value)?.diff }.average() - HeadingResponse.Value(avg) - } else { - data.first() - } + if (data.isEmpty()) return@map HeadingResponse.NoGps + if (data.firstOrNull() !is HeadingResponse.Value) return@map data.first() + + val avgValues = data.mapNotNull { (it as? HeadingResponse.Value)?.diff } + + if (avgValues.isEmpty()) return@map HeadingResponse.NoGps + + val avg = avgValues.average() + + HeadingResponse.Value(avg) } } fun concatenate(vararg flows: Flow) = flow { - var hadNullValue = false - for (flow in flows) { - flow.collect { value -> - if (!hadNullValue) { - emit(value) - if (value == null) hadNullValue = true - } else { - if (value != null) emit(value) - } + emitAll(flow) + } +} + +fun Flow.dropNullsIfNullEncountered(): Flow = flow { + var hadValue = false + + collect { value -> + if (!hadValue) { + emit(value) + if (value != null) hadValue = true + } else { + if (value != null) emit(value) } } } @OptIn(FlowPreview::class) suspend fun KarooSystemService.updateLastKnownGps(context: Context) { - getGpsCoordinateFlow(context) - .filterNotNull() - .throttle(60 * 1_000) // Only update last known gps position once every minute - .collect { gps -> - saveLastKnownPosition(context, gps) - } + while (true) { + getGpsCoordinateFlow(context) + .filterNotNull() + .throttle(60 * 1_000) // Only update last known gps position once every minute + .collect { gps -> + saveLastKnownPosition(context, gps) + } + delay(1_000) + } } @OptIn(FlowPreview::class) fun KarooSystemService.getGpsCoordinateFlow(context: Context): Flow { // return flowOf(GpsCoordinates(52.5164069,13.3784)) - val initialFlow = flow { context.getLastKnownPosition() } + val initialFlow = flow { + val lastKnownPosition = context.getLastKnownPosition() + if (lastKnownPosition != null) emit(lastKnownPosition) + } val gpsFlow = streamDataFlow(DataType.Type.LOCATION) .map { (it as? StreamState.Streaming)?.dataPoint?.values } @@ -384,10 +396,10 @@ fun KarooSystemService.getGpsCoordinateFlow(context: Context): Flow gps to settings } .map { (gps, settings) -> - val rounded = gps?.round(settings.roundLocationTo.km.toDouble()) - if (rounded != null) Log.d(KarooHeadwindExtension.TAG, "Round location to ${settings.roundLocationTo.km} - $rounded") - rounded + gps?.round(settings.roundLocationTo.km.toDouble()) } - .distinctUntilChanged { old, new -> - if (old != null && new != null) { - old.distanceTo(new).absoluteValue < 0.001 - } else { - old == new - } - } - .debounce(Duration.ofSeconds(10)) + .dropNullsIfNullEncountered() } \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/KarooHeadwindExtension.kt b/app/src/main/kotlin/de/timklge/karooheadwind/KarooHeadwindExtension.kt index 95af250..5c57f3e 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/KarooHeadwindExtension.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/KarooHeadwindExtension.kt @@ -23,15 +23,20 @@ import io.hammerhead.karooext.models.UserProfile import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.retry import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.launch +import kotlinx.coroutines.time.debounce +import java.time.Duration +import kotlin.math.absoluteValue import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.minutes @@ -67,7 +72,7 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", "1.1.3") { data class StreamData(val settings: HeadwindSettings, val gps: GpsCoordinates?, val profile: UserProfile? = null) - @OptIn(ExperimentalCoroutinesApi::class) + @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) override fun onCreate() { super.onCreate() @@ -86,6 +91,14 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", "1.1.3") { val gpsFlow = karooSystem .getGpsCoordinateFlow(this@KarooHeadwindExtension) + .distinctUntilChanged { old, new -> + if (old != null && new != null) { + old.distanceTo(new).absoluteValue < 0.001 + } else { + old == new + } + } + .debounce(Duration.ofSeconds(5)) .transformLatest { value: GpsCoordinates? -> while(true){ emit(value) From 9e4c67845e5a190967e2ea1a52c1e0503538ff4d Mon Sep 17 00:00:00 2001 From: Tim Kluge Date: Fri, 3 Jan 2025 16:47:57 +0100 Subject: [PATCH 5/5] Split up files --- .../de/timklge/karooheadwind/DataStore.kt | 162 +++++++++ .../de/timklge/karooheadwind/Extensions.kt | 340 +----------------- .../de/timklge/karooheadwind/HeadingFlow.kt | 145 ++++++++ .../de/timklge/karooheadwind/OpenMeteo.kt | 55 +++ .../de/timklge/karooheadwind/Throttle.kt | 16 - .../kotlin/de/timklge/karooheadwind/Utils.kt | 21 ++ .../datatypes/HeadwindDirectionDataType.kt | 4 - .../timklge/karooheadwind/datatypes/Views.kt | 47 +++ .../datatypes/WeatherDataType.kt | 8 - .../datatypes/WeatherForecastDataType.kt | 1 - 10 files changed, 435 insertions(+), 364 deletions(-) create mode 100644 app/src/main/kotlin/de/timklge/karooheadwind/DataStore.kt create mode 100644 app/src/main/kotlin/de/timklge/karooheadwind/HeadingFlow.kt create mode 100644 app/src/main/kotlin/de/timklge/karooheadwind/OpenMeteo.kt delete mode 100644 app/src/main/kotlin/de/timklge/karooheadwind/Throttle.kt create mode 100644 app/src/main/kotlin/de/timklge/karooheadwind/Utils.kt create mode 100644 app/src/main/kotlin/de/timklge/karooheadwind/datatypes/Views.kt diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/DataStore.kt b/app/src/main/kotlin/de/timklge/karooheadwind/DataStore.kt new file mode 100644 index 0000000..a223ce8 --- /dev/null +++ b/app/src/main/kotlin/de/timklge/karooheadwind/DataStore.kt @@ -0,0 +1,162 @@ +package de.timklge.karooheadwind + +import android.content.Context +import android.util.Log +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import de.timklge.karooheadwind.datatypes.GpsCoordinates +import de.timklge.karooheadwind.screens.HeadwindSettings +import de.timklge.karooheadwind.screens.HeadwindStats +import de.timklge.karooheadwind.screens.HeadwindWidgetSettings +import de.timklge.karooheadwind.screens.WindUnit +import io.hammerhead.karooext.KarooSystemService +import io.hammerhead.karooext.models.UserProfile +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.channels.trySendBlocking +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + + +val jsonWithUnknownKeys = Json { ignoreUnknownKeys = true } + +val settingsKey = stringPreferencesKey("settings") +val widgetSettingsKey = stringPreferencesKey("widgetSettings") +val currentDataKey = stringPreferencesKey("current") +val statsKey = stringPreferencesKey("stats") +val lastKnownPositionKey = stringPreferencesKey("lastKnownPosition") + +suspend fun saveSettings(context: Context, settings: HeadwindSettings) { + context.dataStore.edit { t -> + t[settingsKey] = Json.encodeToString(settings) + } +} + +suspend fun saveWidgetSettings(context: Context, settings: HeadwindWidgetSettings) { + context.dataStore.edit { t -> + t[widgetSettingsKey] = Json.encodeToString(settings) + } +} + +suspend fun saveStats(context: Context, stats: HeadwindStats) { + context.dataStore.edit { t -> + t[statsKey] = Json.encodeToString(stats) + } +} + +suspend fun saveCurrentData(context: Context, forecast: OpenMeteoCurrentWeatherResponse) { + context.dataStore.edit { t -> + t[currentDataKey] = Json.encodeToString(forecast) + } +} + +suspend fun saveLastKnownPosition(context: Context, gpsCoordinates: GpsCoordinates) { + Log.i(KarooHeadwindExtension.TAG, "Saving last known position: $gpsCoordinates") + + try { + context.dataStore.edit { t -> + t[lastKnownPositionKey] = Json.encodeToString(gpsCoordinates) + } + } catch(e: Throwable){ + Log.e(KarooHeadwindExtension.TAG, "Failed to save last known position", e) + } +} + + +fun Context.streamWidgetSettings(): Flow { + return dataStore.data.map { settingsJson -> + try { + if (settingsJson.contains(widgetSettingsKey)){ + jsonWithUnknownKeys.decodeFromString(settingsJson[widgetSettingsKey]!!) + } else { + jsonWithUnknownKeys.decodeFromString(HeadwindWidgetSettings.defaultWidgetSettings) + } + } catch(e: Throwable){ + Log.e(KarooHeadwindExtension.TAG, "Failed to read widget preferences", e) + jsonWithUnknownKeys.decodeFromString(HeadwindWidgetSettings.defaultWidgetSettings) + } + }.distinctUntilChanged() +} + +fun Context.streamSettings(karooSystemService: KarooSystemService): Flow { + return dataStore.data.map { settingsJson -> + try { + if (settingsJson.contains(settingsKey)){ + jsonWithUnknownKeys.decodeFromString(settingsJson[settingsKey]!!) + } else { + val defaultSettings = jsonWithUnknownKeys.decodeFromString(HeadwindSettings.defaultSettings) + + val preferredUnits = karooSystemService.streamUserProfile().first().preferredUnit + + defaultSettings.copy( + windUnit = if (preferredUnits.distance == UserProfile.PreferredUnit.UnitType.METRIC) WindUnit.KILOMETERS_PER_HOUR else WindUnit.MILES_PER_HOUR, + ) + } + } catch(e: Throwable){ + Log.e(KarooHeadwindExtension.TAG, "Failed to read preferences", e) + jsonWithUnknownKeys.decodeFromString(HeadwindSettings.defaultSettings) + } + }.distinctUntilChanged() +} + +fun Context.streamStats(): Flow { + return dataStore.data.map { statsJson -> + try { + jsonWithUnknownKeys.decodeFromString( + statsJson[statsKey] ?: HeadwindStats.defaultStats + ) + } catch(e: Throwable){ + Log.e(KarooHeadwindExtension.TAG, "Failed to read stats", e) + jsonWithUnknownKeys.decodeFromString(HeadwindStats.defaultStats) + } + }.distinctUntilChanged() +} + +suspend fun Context.getLastKnownPosition(): GpsCoordinates? { + val settingsJson = dataStore.data.first() + + try { + val lastKnownPositionString = settingsJson[lastKnownPositionKey] ?: return null + val lastKnownPosition = jsonWithUnknownKeys.decodeFromString( + lastKnownPositionString + ) + + return lastKnownPosition + } catch(e: Throwable){ + Log.e(KarooHeadwindExtension.TAG, "Failed to read last known position", e) + return null + } +} + +fun KarooSystemService.streamUserProfile(): Flow { + return callbackFlow { + val listenerId = addConsumer { userProfile: UserProfile -> + trySendBlocking(userProfile) + } + awaitClose { + removeConsumer(listenerId) + } + } +} + +fun Context.streamCurrentWeatherData(): Flow { + return dataStore.data.map { settingsJson -> + try { + val data = settingsJson[currentDataKey] + data?.let { d -> jsonWithUnknownKeys.decodeFromString(d) } + } catch (e: Throwable) { + Log.e(KarooHeadwindExtension.TAG, "Failed to read weather data", e) + null + } + }.distinctUntilChanged().map { response -> + if (response != null && response.current.time * 1000 >= System.currentTimeMillis() - (1000 * 60 * 60 * 12)){ + response + } else { + null + } + } +} diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/Extensions.kt b/app/src/main/kotlin/de/timklge/karooheadwind/Extensions.kt index 75cdffa..e030229 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/Extensions.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/Extensions.kt @@ -61,50 +61,6 @@ import kotlin.math.abs import kotlin.math.absoluteValue import kotlin.time.Duration.Companion.seconds -val jsonWithUnknownKeys = Json { ignoreUnknownKeys = true } - -val settingsKey = stringPreferencesKey("settings") -val widgetSettingsKey = stringPreferencesKey("widgetSettings") -val currentDataKey = stringPreferencesKey("current") -val statsKey = stringPreferencesKey("stats") -val lastKnownPositionKey = stringPreferencesKey("lastKnownPosition") - -suspend fun saveSettings(context: Context, settings: HeadwindSettings) { - context.dataStore.edit { t -> - t[settingsKey] = Json.encodeToString(settings) - } -} - -suspend fun saveWidgetSettings(context: Context, settings: HeadwindWidgetSettings) { - context.dataStore.edit { t -> - t[widgetSettingsKey] = Json.encodeToString(settings) - } -} - -suspend fun saveStats(context: Context, stats: HeadwindStats) { - context.dataStore.edit { t -> - t[statsKey] = Json.encodeToString(stats) - } -} - -suspend fun saveCurrentData(context: Context, forecast: OpenMeteoCurrentWeatherResponse) { - context.dataStore.edit { t -> - t[currentDataKey] = Json.encodeToString(forecast) - } -} - -suspend fun saveLastKnownPosition(context: Context, gpsCoordinates: GpsCoordinates) { - Log.i(KarooHeadwindExtension.TAG, "Saving last known position: $gpsCoordinates") - - try { - context.dataStore.edit { t -> - t[lastKnownPositionKey] = Json.encodeToString(gpsCoordinates) - } - } catch(e: Throwable){ - Log.e(KarooHeadwindExtension.TAG, "Failed to save last known position", e) - } -} - fun KarooSystemService.streamDataFlow(dataTypeId: String): Flow { return callbackFlow { val listenerId = addConsumer(OnStreamState.StartStreaming(dataTypeId)) { event: OnStreamState -> @@ -116,300 +72,14 @@ fun KarooSystemService.streamDataFlow(dataTypeId: String): Flow { } } -@OptIn(ExperimentalGlanceRemoteViewsApi::class) -suspend fun getErrorWidget(glance: GlanceRemoteViews, context: Context, settings: HeadwindSettings?, headingResponse: HeadingResponse?): RemoteViewsCompositionResult { - return glance.compose(context, DpSize.Unspecified) { - Box(modifier = GlanceModifier.fillMaxSize().padding(5.dp), contentAlignment = Alignment.Center) { - val errorMessage = if (settings?.welcomeDialogAccepted == false) { - "Headwind app not set up" - } else if (headingResponse is HeadingResponse.NoGps){ - "No GPS signal" - } else { - "Weather data download failed" - } - - Log.d(KarooHeadwindExtension.TAG, "Error widget: $errorMessage") - - Text(text = errorMessage, style = TextStyle(fontSize = TextUnit(16f, TextUnitType.Sp), - textAlign = TextAlign.Center, - color = ColorProvider(Color.Black, Color.White))) - } - } -} - -fun Context.streamCurrentWeatherData(): Flow { - return dataStore.data.map { settingsJson -> - try { - val data = settingsJson[currentDataKey] - data?.let { d -> jsonWithUnknownKeys.decodeFromString(d) } - } catch (e: Throwable) { - Log.e(KarooHeadwindExtension.TAG, "Failed to read weather data", e) - null - } - }.distinctUntilChanged().map { response -> - if (response != null && response.current.time * 1000 >= System.currentTimeMillis() - (1000 * 60 * 60 * 12)){ - response - } else { - null - } - } -} - -fun Context.streamWidgetSettings(): Flow { - return dataStore.data.map { settingsJson -> - try { - if (settingsJson.contains(widgetSettingsKey)){ - jsonWithUnknownKeys.decodeFromString(settingsJson[widgetSettingsKey]!!) - } else { - jsonWithUnknownKeys.decodeFromString(HeadwindWidgetSettings.defaultWidgetSettings) - } - } catch(e: Throwable){ - Log.e(KarooHeadwindExtension.TAG, "Failed to read widget preferences", e) - jsonWithUnknownKeys.decodeFromString(HeadwindWidgetSettings.defaultWidgetSettings) - } - }.distinctUntilChanged() -} - -fun Context.streamSettings(karooSystemService: KarooSystemService): Flow { - return dataStore.data.map { settingsJson -> - try { - if (settingsJson.contains(settingsKey)){ - jsonWithUnknownKeys.decodeFromString(settingsJson[settingsKey]!!) - } else { - val defaultSettings = jsonWithUnknownKeys.decodeFromString(HeadwindSettings.defaultSettings) - - val preferredUnits = karooSystemService.streamUserProfile().first().preferredUnit - - defaultSettings.copy( - windUnit = if (preferredUnits.distance == UserProfile.PreferredUnit.UnitType.METRIC) WindUnit.KILOMETERS_PER_HOUR else WindUnit.MILES_PER_HOUR, - ) - } - } catch(e: Throwable){ - Log.e(KarooHeadwindExtension.TAG, "Failed to read preferences", e) - jsonWithUnknownKeys.decodeFromString(HeadwindSettings.defaultSettings) - } - }.distinctUntilChanged() -} - -fun Context.streamStats(): Flow { - return dataStore.data.map { statsJson -> - try { - jsonWithUnknownKeys.decodeFromString( - statsJson[statsKey] ?: HeadwindStats.defaultStats - ) - } catch(e: Throwable){ - Log.e(KarooHeadwindExtension.TAG, "Failed to read stats", e) - jsonWithUnknownKeys.decodeFromString(HeadwindStats.defaultStats) - } - }.distinctUntilChanged() -} - -suspend fun Context.getLastKnownPosition(): GpsCoordinates? { - val settingsJson = dataStore.data.first() - - try { - val lastKnownPositionString = settingsJson[lastKnownPositionKey] ?: return null - val lastKnownPosition = jsonWithUnknownKeys.decodeFromString( - lastKnownPositionString - ) - - return lastKnownPosition - } catch(e: Throwable){ - Log.e(KarooHeadwindExtension.TAG, "Failed to read last known position", e) - return null - } -} - -fun KarooSystemService.streamUserProfile(): Flow { - return callbackFlow { - val listenerId = addConsumer { userProfile: UserProfile -> - trySendBlocking(userProfile) - } - awaitClose { - removeConsumer(listenerId) - } - } -} - -@OptIn(FlowPreview::class) -suspend fun KarooSystemService.makeOpenMeteoHttpRequest(gpsCoordinates: GpsCoordinates, settings: HeadwindSettings, profile: UserProfile?): HttpResponseState.Complete { - val precipitationUnit = if (profile?.preferredUnit?.distance != UserProfile.PreferredUnit.UnitType.IMPERIAL) PrecipitationUnit.MILLIMETERS else PrecipitationUnit.INCH - val temperatureUnit = if (profile?.preferredUnit?.temperature != UserProfile.PreferredUnit.UnitType.IMPERIAL) TemperatureUnit.CELSIUS else TemperatureUnit.FAHRENHEIT - - return callbackFlow { - // https://api.open-meteo.com/v1/forecast?latitude=52.52&longitude=13.41¤t=surface_pressure,temperature_2m,relative_humidity_2m,precipitation,weather_code,cloud_cover,wind_speed_10m,wind_direction_10m,wind_gusts_10m&hourly=temperature_2m,precipitation_probability,precipitation,weather_code,wind_speed_10m,wind_direction_10m,wind_gusts_10m&timeformat=unixtime&past_hours=1&forecast_days=1&forecast_hours=12 - val url = "https://api.open-meteo.com/v1/forecast?latitude=${gpsCoordinates.lat}&longitude=${gpsCoordinates.lon}¤t=surface_pressure,temperature_2m,relative_humidity_2m,precipitation,weather_code,cloud_cover,wind_speed_10m,wind_direction_10m,wind_gusts_10m&hourly=temperature_2m,precipitation_probability,precipitation,weather_code,wind_speed_10m,wind_direction_10m,wind_gusts_10m&timeformat=unixtime&past_hours=0&forecast_days=1&forecast_hours=12&wind_speed_unit=${settings.windUnit.id}&precipitation_unit=${precipitationUnit.id}&temperature_unit=${temperatureUnit.id}" - - Log.d(KarooHeadwindExtension.TAG, "Http request to ${url}...") - - val listenerId = addConsumer( - OnHttpResponse.MakeHttpRequest( - "GET", - url, - waitForConnection = false, - ), - ) { event: OnHttpResponse -> - Log.d(KarooHeadwindExtension.TAG, "Http response event $event") - if (event.state is HttpResponseState.Complete){ - trySend(event.state as HttpResponseState.Complete) - close() - } - } - awaitClose { - removeConsumer(listenerId) - } - }.timeout(20.seconds).catch { e: Throwable -> - if (e is TimeoutCancellationException){ - emit(HttpResponseState.Complete(500, mapOf(), null, "Timeout")) - } else { - throw e - } - }.single() -} - -fun signedAngleDifference(angle1: Double, angle2: Double): Double { - val a1 = angle1 % 360 - val a2 = angle2 % 360 - var diff = abs(a1 - a2) - - val sign = if (a1 < a2) { - if (diff > 180.0) -1 else 1 - } else { - if (diff > 180.0) 1 else -1 - } - - if (diff > 180.0) { - diff = 360.0 - diff - } - - return sign * diff -} - -sealed class HeadingResponse { - data object NoGps: HeadingResponse() - data object NoWeatherData: HeadingResponse() - data class Value(val diff: Double): HeadingResponse() -} - -fun KarooSystemService.getRelativeHeadingFlow(context: Context): Flow { - val currentWeatherData = context.streamCurrentWeatherData() - - return getHeadingFlow(context) - .combine(currentWeatherData) { bearing, data -> bearing to data } - .map { (bearing, data) -> - when { - bearing is HeadingResponse.Value && data != null -> { - val windBearing = data.current.windDirection + 180 - val diff = signedAngleDifference(bearing.diff, windBearing) - - Log.d(KarooHeadwindExtension.TAG, "Wind bearing: $bearing vs $windBearing => $diff") - - HeadingResponse.Value(diff) - } - bearing is HeadingResponse.NoGps -> HeadingResponse.NoGps - bearing is HeadingResponse.NoWeatherData || data == null -> HeadingResponse.NoWeatherData - else -> bearing - } - } -} - -fun KarooSystemService.getHeadingFlow(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") - val headingValue = heading?.let { HeadingResponse.Value(it) } - - headingValue ?: HeadingResponse.NoGps - } - .distinctUntilChanged() - .scan(emptyList()) { acc, value -> /* Average over 3 values */ - if (value !is HeadingResponse.Value) return@scan listOf(value) - - val newAcc = acc + value - if (newAcc.size > 3) newAcc.drop(1) else newAcc - } - .map { data -> - Log.i(KarooHeadwindExtension.TAG, "Heading value: $data") - - if (data.isEmpty()) return@map HeadingResponse.NoGps - if (data.firstOrNull() !is HeadingResponse.Value) return@map data.first() - - val avgValues = data.mapNotNull { (it as? HeadingResponse.Value)?.diff } - - if (avgValues.isEmpty()) return@map HeadingResponse.NoGps - - val avg = avgValues.average() - - HeadingResponse.Value(avg) - } -} - -fun concatenate(vararg flows: Flow) = flow { - for (flow in flows) { - emitAll(flow) - } -} - -fun Flow.dropNullsIfNullEncountered(): Flow = flow { - var hadValue = false +fun Flow.throttle(timeout: Long): Flow = flow { + var lastEmissionTime = 0L collect { value -> - if (!hadValue) { + val currentTime = System.currentTimeMillis() + if (currentTime - lastEmissionTime >= timeout) { emit(value) - if (value != null) hadValue = true - } else { - if (value != null) emit(value) + lastEmissionTime = currentTime } } -} - -@OptIn(FlowPreview::class) -suspend fun KarooSystemService.updateLastKnownGps(context: Context) { - while (true) { - getGpsCoordinateFlow(context) - .filterNotNull() - .throttle(60 * 1_000) // Only update last known gps position once every minute - .collect { gps -> - saveLastKnownPosition(context, gps) - } - delay(1_000) - } -} - -@OptIn(FlowPreview::class) -fun KarooSystemService.getGpsCoordinateFlow(context: Context): Flow { - // return flowOf(GpsCoordinates(52.5164069,13.3784)) - - val initialFlow = flow { - val lastKnownPosition = context.getLastKnownPosition() - if (lastKnownPosition != null) emit(lastKnownPosition) - } - - val gpsFlow = streamDataFlow(DataType.Type.LOCATION) - .map { (it as? StreamState.Streaming)?.dataPoint?.values } - .map { values -> - val lat = values?.get(DataType.Field.LOC_LATITUDE) - val lon = values?.get(DataType.Field.LOC_LONGITUDE) - val bearing = values?.get(DataType.Field.LOC_BEARING) - - if (lat != null && lon != null){ - // Log.d(KarooHeadwindExtension.TAG, "Updated gps coordinates: $lat $lon") - GpsCoordinates(lat, lon, bearing) - } else { - // Log.w(KarooHeadwindExtension.TAG, "Gps unavailable: $values") - null - } - } - - val concatenatedFlow = concatenate(initialFlow, gpsFlow) - - return concatenatedFlow - .combine(context.streamSettings(this)) { gps, settings -> gps to settings } - .map { (gps, settings) -> - gps?.round(settings.roundLocationTo.km.toDouble()) - } - .dropNullsIfNullEncountered() } \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/HeadingFlow.kt b/app/src/main/kotlin/de/timklge/karooheadwind/HeadingFlow.kt new file mode 100644 index 0000000..7855fda --- /dev/null +++ b/app/src/main/kotlin/de/timklge/karooheadwind/HeadingFlow.kt @@ -0,0 +1,145 @@ +package de.timklge.karooheadwind + +import android.content.Context +import android.util.Log +import de.timklge.karooheadwind.datatypes.GpsCoordinates +import io.hammerhead.karooext.KarooSystemService +import io.hammerhead.karooext.models.DataType +import io.hammerhead.karooext.models.StreamState +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.scan + + +sealed class HeadingResponse { + data object NoGps: HeadingResponse() + data object NoWeatherData: HeadingResponse() + data class Value(val diff: Double): HeadingResponse() +} + +fun KarooSystemService.getRelativeHeadingFlow(context: Context): Flow { + val currentWeatherData = context.streamCurrentWeatherData() + + return getHeadingFlow(context) + .combine(currentWeatherData) { bearing, data -> bearing to data } + .map { (bearing, data) -> + when { + bearing is HeadingResponse.Value && data != null -> { + val windBearing = data.current.windDirection + 180 + val diff = signedAngleDifference(bearing.diff, windBearing) + + Log.d(KarooHeadwindExtension.TAG, "Wind bearing: $bearing vs $windBearing => $diff") + + HeadingResponse.Value(diff) + } + bearing is HeadingResponse.NoGps -> HeadingResponse.NoGps + bearing is HeadingResponse.NoWeatherData || data == null -> HeadingResponse.NoWeatherData + else -> bearing + } + } +} + +fun KarooSystemService.getHeadingFlow(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") + val headingValue = heading?.let { HeadingResponse.Value(it) } + + headingValue ?: HeadingResponse.NoGps + } + .distinctUntilChanged() + .scan(emptyList()) { acc, value -> /* Average over 3 values */ + if (value !is HeadingResponse.Value) return@scan listOf(value) + + val newAcc = acc + value + if (newAcc.size > 3) newAcc.drop(1) else newAcc + } + .map { data -> + Log.i(KarooHeadwindExtension.TAG, "Heading value: $data") + + if (data.isEmpty()) return@map HeadingResponse.NoGps + if (data.firstOrNull() !is HeadingResponse.Value) return@map data.first() + + val avgValues = data.mapNotNull { (it as? HeadingResponse.Value)?.diff } + + if (avgValues.isEmpty()) return@map HeadingResponse.NoGps + + val avg = avgValues.average() + + HeadingResponse.Value(avg) + } +} + +fun concatenate(vararg flows: Flow) = flow { + for (flow in flows) { + emitAll(flow) + } +} + +fun Flow.dropNullsIfNullEncountered(): Flow = flow { + var hadValue = false + + collect { value -> + if (!hadValue) { + emit(value) + if (value != null) hadValue = true + } else { + if (value != null) emit(value) + } + } +} + +suspend fun KarooSystemService.updateLastKnownGps(context: Context) { + while (true) { + getGpsCoordinateFlow(context) + .filterNotNull() + .throttle(60 * 1_000) // Only update last known gps position once every minute + .collect { gps -> + saveLastKnownPosition(context, gps) + } + delay(1_000) + } +} + +fun KarooSystemService.getGpsCoordinateFlow(context: Context): Flow { + // return flowOf(GpsCoordinates(52.5164069,13.3784)) + + val initialFlow = flow { + val lastKnownPosition = context.getLastKnownPosition() + if (lastKnownPosition != null) emit(lastKnownPosition) + } + + val gpsFlow = streamDataFlow(DataType.Type.LOCATION) + .map { (it as? StreamState.Streaming)?.dataPoint?.values } + .map { values -> + val lat = values?.get(DataType.Field.LOC_LATITUDE) + val lon = values?.get(DataType.Field.LOC_LONGITUDE) + val bearing = values?.get(DataType.Field.LOC_BEARING) + + if (lat != null && lon != null){ + // Log.d(KarooHeadwindExtension.TAG, "Updated gps coordinates: $lat $lon") + GpsCoordinates(lat, lon, bearing) + } else { + // Log.w(KarooHeadwindExtension.TAG, "Gps unavailable: $values") + null + } + } + + val concatenatedFlow = concatenate(initialFlow, gpsFlow) + + return concatenatedFlow + .combine(context.streamSettings(this)) { gps, settings -> gps to settings } + .map { (gps, settings) -> + gps?.round(settings.roundLocationTo.km.toDouble()) + } + .dropNullsIfNullEncountered() +} \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/OpenMeteo.kt b/app/src/main/kotlin/de/timklge/karooheadwind/OpenMeteo.kt new file mode 100644 index 0000000..a1add54 --- /dev/null +++ b/app/src/main/kotlin/de/timklge/karooheadwind/OpenMeteo.kt @@ -0,0 +1,55 @@ +package de.timklge.karooheadwind + +import android.util.Log +import de.timklge.karooheadwind.datatypes.GpsCoordinates +import de.timklge.karooheadwind.screens.HeadwindSettings +import de.timklge.karooheadwind.screens.PrecipitationUnit +import de.timklge.karooheadwind.screens.TemperatureUnit +import io.hammerhead.karooext.KarooSystemService +import io.hammerhead.karooext.models.HttpResponseState +import io.hammerhead.karooext.models.OnHttpResponse +import io.hammerhead.karooext.models.UserProfile +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.single +import kotlinx.coroutines.flow.timeout +import kotlin.time.Duration.Companion.seconds + +@OptIn(FlowPreview::class) +suspend fun KarooSystemService.makeOpenMeteoHttpRequest(gpsCoordinates: GpsCoordinates, settings: HeadwindSettings, profile: UserProfile?): HttpResponseState.Complete { + val precipitationUnit = if (profile?.preferredUnit?.distance != UserProfile.PreferredUnit.UnitType.IMPERIAL) PrecipitationUnit.MILLIMETERS else PrecipitationUnit.INCH + val temperatureUnit = if (profile?.preferredUnit?.temperature != UserProfile.PreferredUnit.UnitType.IMPERIAL) TemperatureUnit.CELSIUS else TemperatureUnit.FAHRENHEIT + + return callbackFlow { + // https://api.open-meteo.com/v1/forecast?latitude=52.52&longitude=13.41¤t=surface_pressure,temperature_2m,relative_humidity_2m,precipitation,weather_code,cloud_cover,wind_speed_10m,wind_direction_10m,wind_gusts_10m&hourly=temperature_2m,precipitation_probability,precipitation,weather_code,wind_speed_10m,wind_direction_10m,wind_gusts_10m&timeformat=unixtime&past_hours=1&forecast_days=1&forecast_hours=12 + val url = "https://api.open-meteo.com/v1/forecast?latitude=${gpsCoordinates.lat}&longitude=${gpsCoordinates.lon}¤t=surface_pressure,temperature_2m,relative_humidity_2m,precipitation,weather_code,cloud_cover,wind_speed_10m,wind_direction_10m,wind_gusts_10m&hourly=temperature_2m,precipitation_probability,precipitation,weather_code,wind_speed_10m,wind_direction_10m,wind_gusts_10m&timeformat=unixtime&past_hours=0&forecast_days=1&forecast_hours=12&wind_speed_unit=${settings.windUnit.id}&precipitation_unit=${precipitationUnit.id}&temperature_unit=${temperatureUnit.id}" + + Log.d(KarooHeadwindExtension.TAG, "Http request to ${url}...") + + val listenerId = addConsumer( + OnHttpResponse.MakeHttpRequest( + "GET", + url, + waitForConnection = false, + ), + ) { event: OnHttpResponse -> + Log.d(KarooHeadwindExtension.TAG, "Http response event $event") + if (event.state is HttpResponseState.Complete){ + trySend(event.state as HttpResponseState.Complete) + close() + } + } + awaitClose { + removeConsumer(listenerId) + } + }.timeout(20.seconds).catch { e: Throwable -> + if (e is TimeoutCancellationException){ + emit(HttpResponseState.Complete(500, mapOf(), null, "Timeout")) + } else { + throw e + } + }.single() +} diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/Throttle.kt b/app/src/main/kotlin/de/timklge/karooheadwind/Throttle.kt deleted file mode 100644 index 1fd88bc..0000000 --- a/app/src/main/kotlin/de/timklge/karooheadwind/Throttle.kt +++ /dev/null @@ -1,16 +0,0 @@ -package de.timklge.karooheadwind - -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow - -fun Flow.throttle(timeout: Long): Flow = flow { - var lastEmissionTime = 0L - - collect { value -> - val currentTime = System.currentTimeMillis() - if (currentTime - lastEmissionTime >= timeout) { - emit(value) - lastEmissionTime = currentTime - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/Utils.kt b/app/src/main/kotlin/de/timklge/karooheadwind/Utils.kt new file mode 100644 index 0000000..512e795 --- /dev/null +++ b/app/src/main/kotlin/de/timklge/karooheadwind/Utils.kt @@ -0,0 +1,21 @@ +package de.timklge.karooheadwind + +import kotlin.math.abs + +fun signedAngleDifference(angle1: Double, angle2: Double): Double { + val a1 = angle1 % 360 + val a2 = angle2 % 360 + var diff = abs(a1 - a2) + + val sign = if (a1 < a2) { + if (diff > 180.0) -1 else 1 + } else { + if (diff > 180.0) 1 else -1 + } + + if (diff > 180.0) { + diff = 360.0 - diff + } + + return sign * diff +} \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindDirectionDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindDirectionDataType.kt index ee14750..832532c 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindDirectionDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindDirectionDataType.kt @@ -8,13 +8,11 @@ import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi import androidx.glance.appwidget.GlanceRemoteViews import de.timklge.karooheadwind.HeadingResponse import de.timklge.karooheadwind.KarooHeadwindExtension -import de.timklge.karooheadwind.getErrorWidget import de.timklge.karooheadwind.getRelativeHeadingFlow import de.timklge.karooheadwind.screens.HeadwindSettings import de.timklge.karooheadwind.screens.WindDirectionIndicatorSetting import de.timklge.karooheadwind.screens.WindDirectionIndicatorTextSetting import de.timklge.karooheadwind.streamCurrentWeatherData -import de.timklge.karooheadwind.streamDataFlow import de.timklge.karooheadwind.streamSettings import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.extension.DataTypeImpl @@ -32,8 +30,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.mapNotNull -import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.launch import kotlin.math.cos import kotlin.math.roundToInt diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/Views.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/Views.kt new file mode 100644 index 0000000..81d054d --- /dev/null +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/Views.kt @@ -0,0 +1,47 @@ +package de.timklge.karooheadwind.datatypes + +import android.content.Context +import android.util.Log +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.TextUnitType +import androidx.compose.ui.unit.dp +import androidx.glance.GlanceModifier +import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi +import androidx.glance.appwidget.GlanceRemoteViews +import androidx.glance.appwidget.RemoteViewsCompositionResult +import androidx.glance.color.ColorProvider +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.padding +import androidx.glance.text.Text +import androidx.glance.text.TextAlign +import androidx.glance.text.TextStyle +import de.timklge.karooheadwind.HeadingResponse +import de.timklge.karooheadwind.KarooHeadwindExtension +import de.timklge.karooheadwind.screens.HeadwindSettings + +@OptIn(ExperimentalGlanceRemoteViewsApi::class) +suspend fun getErrorWidget(glance: GlanceRemoteViews, context: Context, settings: HeadwindSettings?, headingResponse: HeadingResponse?): RemoteViewsCompositionResult { + return glance.compose(context, DpSize.Unspecified) { + Box(modifier = GlanceModifier.fillMaxSize().padding(5.dp), contentAlignment = Alignment.Center) { + val errorMessage = if (settings?.welcomeDialogAccepted == false) { + "Headwind app not set up" + } else if (headingResponse is HeadingResponse.NoGps){ + "No GPS signal" + } else { + "Weather data download failed" + } + + Log.d(KarooHeadwindExtension.TAG, "Error widget: $errorMessage") + + Text(text = errorMessage, style = TextStyle(fontSize = TextUnit(16f, TextUnitType.Sp), + textAlign = TextAlign.Center, + color = ColorProvider(Color.Black, Color.White) + ) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherDataType.kt index 1d33083..eab2beb 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherDataType.kt @@ -4,21 +4,16 @@ import android.content.Context import android.graphics.BitmapFactory import android.util.Log import androidx.compose.ui.unit.DpSize -import androidx.compose.ui.unit.TextUnit -import androidx.compose.ui.unit.TextUnitType import androidx.glance.GlanceModifier import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi import androidx.glance.appwidget.GlanceRemoteViews import androidx.glance.layout.Alignment import androidx.glance.layout.Box import androidx.glance.layout.fillMaxSize -import androidx.glance.text.Text -import androidx.glance.text.TextStyle import de.timklge.karooheadwind.HeadingResponse import de.timklge.karooheadwind.KarooHeadwindExtension import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse import de.timklge.karooheadwind.WeatherInterpretation -import de.timklge.karooheadwind.getErrorWidget import de.timklge.karooheadwind.getHeadingFlow import de.timklge.karooheadwind.screens.HeadwindSettings import de.timklge.karooheadwind.screens.PrecipitationUnit @@ -40,9 +35,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filterNot -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.launch import java.time.Instant import java.time.ZoneId diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherForecastDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherForecastDataType.kt index cd1e582..7e9110b 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherForecastDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherForecastDataType.kt @@ -26,7 +26,6 @@ import de.timklge.karooheadwind.HeadingResponse import de.timklge.karooheadwind.KarooHeadwindExtension import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse import de.timklge.karooheadwind.WeatherInterpretation -import de.timklge.karooheadwind.getErrorWidget import de.timklge.karooheadwind.getHeadingFlow import de.timklge.karooheadwind.saveWidgetSettings import de.timklge.karooheadwind.screens.HeadwindSettings