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