From efaa8efc2c7e733fae9bdd6b32984894787d10ba Mon Sep 17 00:00:00 2001 From: timklge <2026103+timklge@users.noreply.github.com> Date: Mon, 7 Apr 2025 21:47:37 +0200 Subject: [PATCH] Use interpolated forecast data to update current weather data, refactor weather provider code (#83) * Use interpolated forecast data to update current weather data, refactor weather provider code * Fix interpolation between locations * Fix copypaste error message for open meteo http * Fix error display * Show interpolated time * Fix position forecasts after refactoring * fix #84: Add lerpAngle * fix #85: Fix weather widget shows wind direction rotated by 180 degrees * Make red background color slightly lighter to improve contrast --- .../de/timklge/karooheadwind/DataStore.kt | 217 +++++++++++++-- .../de/timklge/karooheadwind/HeadingFlow.kt | 12 +- .../karooheadwind/KarooHeadwindExtension.kt | 112 +++----- .../de/timklge/karooheadwind/OpenMeteo.kt | 85 ------ .../de/timklge/karooheadwind/OpenMeteoData.kt | 64 ----- .../karooheadwind/OpenMeteoProvider.kt | 39 --- .../karooheadwind/OpenWeatherMapProvider.kt | 248 ------------------ .../karooheadwind/WeatherProviderFactory.kt | 71 ----- .../karooheadwind/datatypes/BaseDataType.kt | 13 +- .../datatypes/CloudCoverDataType.kt | 9 +- .../datatypes/CycleHoursAction.kt | 10 +- .../datatypes/ForecastDataType.kt | 172 ++++++------ .../datatypes/GraphicalForecast.kt | 4 +- .../datatypes/HeadwindDirectionDataType.kt | 4 +- .../datatypes/HeadwindSpeedDataType.kt | 12 +- .../datatypes/PrecipitationDataType.kt | 9 +- .../PrecipitationForecastDataType.kt | 2 +- .../datatypes/RelativeHumidityDataType.kt | 9 +- .../datatypes/SealevelPressureDataType.kt | 9 +- .../datatypes/SurfacePressureDataType.kt | 9 +- .../datatypes/TailwindAndRideSpeedDataType.kt | 10 +- .../datatypes/TailwindDataType.kt | 10 +- .../datatypes/TemperatureDataType.kt | 9 +- .../datatypes/TemperatureForecastDataType.kt | 2 +- .../datatypes/UserWindSpeedDataType.kt | 10 +- .../datatypes/WeatherDataType.kt | 43 ++- .../datatypes/WeatherForecastDataType.kt | 2 +- .../karooheadwind/datatypes/WeatherView.kt | 2 +- .../datatypes/WindDirectionDataType.kt | 8 +- .../datatypes/WindForecastDataType.kt | 4 +- .../datatypes/WindGustsDataType.kt | 9 +- .../datatypes/WindSpeedDataType.kt | 9 +- .../karooheadwind/screens/WeatherScreen.kt | 87 +++--- .../karooheadwind/screens/WeatherWidget.kt | 6 +- .../{Utils.kt => util/AngleDifference.kt} | 2 +- .../de/timklge/karooheadwind/util/Gzip.kt | 19 ++ .../weatherprovider/WeatherData.kt | 21 ++ .../weatherprovider/WeatherDataForLocation.kt | 13 + .../weatherprovider/WeatherDataResponse.kt | 11 + .../weatherprovider/WeatherInterpretation.kt | 22 ++ .../{ => weatherprovider}/WeatherProvider.kt | 6 +- .../WeatherProviderException.kt | 3 + .../weatherprovider/WeatherProviderFactory.kt | 82 ++++++ .../openmeteo/OpenMeteoWeatherData.kt | 36 +++ .../OpenMeteoWeatherDataForLocation.kt | 28 ++ .../openmeteo/OpenMeteoWeatherForecastData.kt | 32 +++ .../openmeteo/OpenMeteoWeatherProvider.kt | 103 ++++++++ .../OpenWeatherMapForecastData.kt | 39 +++ .../OpenWeatherMapWeatherData.kt | 40 +++ .../OpenWeatherMapWeatherDataForLocation.kt | 23 ++ .../OpenWeatherMapWeatherProvider.kt | 143 ++++++++++ app/src/main/res/values/colors.xml | 4 +- 52 files changed, 1087 insertions(+), 861 deletions(-) delete mode 100644 app/src/main/kotlin/de/timklge/karooheadwind/OpenMeteo.kt delete mode 100644 app/src/main/kotlin/de/timklge/karooheadwind/OpenMeteoData.kt delete mode 100644 app/src/main/kotlin/de/timklge/karooheadwind/OpenMeteoProvider.kt delete mode 100644 app/src/main/kotlin/de/timklge/karooheadwind/OpenWeatherMapProvider.kt delete mode 100644 app/src/main/kotlin/de/timklge/karooheadwind/WeatherProviderFactory.kt rename app/src/main/kotlin/de/timklge/karooheadwind/{Utils.kt => util/AngleDifference.kt} (90%) create mode 100644 app/src/main/kotlin/de/timklge/karooheadwind/util/Gzip.kt create mode 100644 app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/WeatherData.kt create mode 100644 app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/WeatherDataForLocation.kt create mode 100644 app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/WeatherDataResponse.kt create mode 100644 app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/WeatherInterpretation.kt rename app/src/main/kotlin/de/timklge/karooheadwind/{ => weatherprovider}/WeatherProvider.kt (74%) create mode 100644 app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/WeatherProviderException.kt create mode 100644 app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/WeatherProviderFactory.kt create mode 100644 app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openmeteo/OpenMeteoWeatherData.kt create mode 100644 app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openmeteo/OpenMeteoWeatherDataForLocation.kt create mode 100644 app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openmeteo/OpenMeteoWeatherForecastData.kt create mode 100644 app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openmeteo/OpenMeteoWeatherProvider.kt create mode 100644 app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openweathermap/OpenWeatherMapForecastData.kt create mode 100644 app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openweathermap/OpenWeatherMapWeatherData.kt create mode 100644 app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openweathermap/OpenWeatherMapWeatherDataForLocation.kt create mode 100644 app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openweathermap/OpenWeatherMapWeatherProvider.kt diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/DataStore.kt b/app/src/main/kotlin/de/timklge/karooheadwind/DataStore.kt index 0b1fdc1..cfc9413 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/DataStore.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/DataStore.kt @@ -5,33 +5,41 @@ import android.util.Log import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import com.mapbox.geojson.LineString +import com.mapbox.geojson.Point import com.mapbox.turf.TurfConstants import com.mapbox.turf.TurfMeasurement import de.timklge.karooheadwind.datatypes.GpsCoordinates +import de.timklge.karooheadwind.weatherprovider.WeatherData +import de.timklge.karooheadwind.weatherprovider.WeatherDataForLocation +import de.timklge.karooheadwind.weatherprovider.WeatherDataResponse import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.models.DataType import io.hammerhead.karooext.models.OnNavigationState import io.hammerhead.karooext.models.StreamState import io.hammerhead.karooext.models.UserProfile +import kotlinx.coroutines.ExperimentalCoroutinesApi 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.combine import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map -import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json +import kotlin.math.absoluteValue +import kotlin.time.Duration.Companion.minutes val jsonWithUnknownKeys = Json { ignoreUnknownKeys = true } val settingsKey = stringPreferencesKey("settings") val widgetSettingsKey = stringPreferencesKey("widgetSettings") -val currentDataKey = stringPreferencesKey("currentForecasts") +val currentDataKey = stringPreferencesKey("currentForecastsUnified") val statsKey = stringPreferencesKey("stats") val lastKnownPositionKey = stringPreferencesKey("lastKnownPosition") @@ -53,12 +61,9 @@ suspend fun saveStats(context: Context, stats: HeadwindStats) { } } -@Serializable -data class WeatherDataResponse(val data: OpenMeteoCurrentWeatherResponse, val requestedPosition: GpsCoordinates) - -suspend fun saveCurrentData(context: Context, forecast: List) { +suspend fun saveCurrentData(context: Context, response: WeatherDataResponse) { context.dataStore.edit { t -> - t[currentDataKey] = Json.encodeToString(forecast) + t[currentDataKey] = Json.encodeToString(response) } } @@ -114,13 +119,9 @@ fun Context.streamSettings(karooSystemService: KarooSystemService): Flow { - val distanceToDestinationStream = flow { - emit(null) - - streamDataFlow(DataType.Type.DISTANCE_TO_DESTINATION) - .map { (it as? StreamState.Streaming)?.dataPoint?.values?.get(DataType.Field.DISTANCE_TO_DESTINATION) } - .collect { emit(it) } - } + val distanceToDestinationStream = streamDataFlow(DataType.Type.DISTANCE_TO_DESTINATION) + .map { (it as? StreamState.Streaming)?.dataPoint?.values?.get(DataType.Field.DISTANCE_TO_DESTINATION) } + .distinctUntilChanged() var lastKnownDistanceAlongRoute = 0.0 var lastKnownRoutePolyline: LineString? = null @@ -130,6 +131,7 @@ fun KarooSystemService.streamUpcomingRoute(): Flow { .map { navigationState -> navigationState?.let { LineString.fromPolyline(it.routePolyline, 5) } } + .distinctUntilChanged() .combine(distanceToDestinationStream) { routePolyline, distanceToDestination -> Log.d(KarooHeadwindExtension.TAG, "Route polyline size: ${routePolyline?.coordinates()?.size}, distance to destination: $distanceToDestination") if (routePolyline != null){ @@ -190,18 +192,191 @@ fun KarooSystemService.streamUserProfile(): Flow { } } -fun Context.streamCurrentWeatherData(): Flow> { +fun Context.streamCurrentForecastWeatherData(): Flow { return dataStore.data.map { settingsJson -> try { val data = settingsJson[currentDataKey] - data?.let { d -> jsonWithUnknownKeys.decodeFromString>(d) } ?: emptyList() + + data?.let { d -> jsonWithUnknownKeys.decodeFromString(d) } } catch (e: Throwable) { Log.e(KarooHeadwindExtension.TAG, "Failed to read weather data", e) - emptyList() + null } - }.distinctUntilChanged().map { response -> - response.filter { forecast -> - forecast.data.current.time * 1000 >= System.currentTimeMillis() - (1000 * 60 * 60 * 12) + }.distinctUntilChanged() +} + +fun lerpNullable( + start: Double?, + end: Double?, + factor: Double +): Double? { + if (start == null && end == null) return null + if (start == null) return end + if (end == null) return start + + return start + (end - start) * factor +} + +/** + * Linearly interpolates between two angles in degrees + * + * @param start Starting angle in degrees [0-360] + * @param end Ending angle in degrees [0-360] + * @param factor Interpolation factor [0-1] + * @return Interpolated angle in degrees [0-360] + */ +fun lerpAngle( + start: Double, + end: Double, + factor: Double +): Double { + val normalizedStart = start % 360.0 + val normalizedEnd = end % 360.0 + + var diff = normalizedEnd - normalizedStart + if (diff > 180.0) { + diff -= 360.0 + } else if (diff < -180.0) { + diff += 360.0 + } + + var result = normalizedStart + diff * factor + + if (result < 0) { + result += 360.0 + } else if (result >= 360.0) { + result -= 360.0 + } + + return result +} + +fun lerpWeather( + start: WeatherData, + end: WeatherData, + factor: Double +): WeatherData { + val closestWeatherData = if (factor < 0.5) start else end + + return WeatherData( + time = (start.time + (end.time - start.time) * factor).toLong(), + temperature = start.temperature + (end.temperature - start.temperature) * factor, + relativeHumidity = lerpNullable(start.relativeHumidity, end.relativeHumidity, factor), + precipitation = start.precipitation + (end.precipitation - start.precipitation) * factor, + cloudCover = lerpNullable(start.cloudCover, end.cloudCover, factor), + surfacePressure = lerpNullable(start.surfacePressure, end.surfacePressure, factor), + sealevelPressure = lerpNullable(start.sealevelPressure, end.sealevelPressure, factor), + windSpeed = start.windSpeed + (end.windSpeed - start.windSpeed) * factor, + windDirection = lerpAngle(start.windDirection, end.windDirection, factor), + windGusts = start.windGusts + (end.windGusts - start.windGusts) * factor, + weatherCode = closestWeatherData.weatherCode, + isForecast = closestWeatherData.isForecast + ) +} + +fun lerpWeatherTime( + weatherData: List?, + currentWeatherData: WeatherData +): WeatherData { + val now = System.currentTimeMillis() + val nextWeatherForecastData = weatherData?.find { forecast -> forecast.time * 1000 >= now } + val previousWeatherForecastData = weatherData?.findLast { forecast -> forecast.time * 1000 < now } + + val interpolateStartWeatherData = previousWeatherForecastData ?: currentWeatherData + val interpolateEndWeatherData = nextWeatherForecastData ?: interpolateStartWeatherData + + val lerpFactor = ((now - (interpolateStartWeatherData.time * 1000)).toDouble() / (interpolateEndWeatherData.time * 1000 - (interpolateStartWeatherData.time * 1000)).absoluteValue).coerceIn(0.0, 1.0) + + return lerpWeather( + start = interpolateStartWeatherData, + end = interpolateEndWeatherData, + factor = lerpFactor + ) +} + +@OptIn(ExperimentalCoroutinesApi::class) +fun Context.streamCurrentWeatherData(karooSystemService: KarooSystemService): Flow { + val locationFlow = flow { + emit(null) + emitAll(karooSystemService.getGpsCoordinateFlow(this@streamCurrentWeatherData)) + } + + 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 + } + }.combine(locationFlow) { + weatherData, location -> weatherData to location + }.distinctUntilChanged() + .flatMapLatest { (weatherData, location) -> + flow { + if (!weatherData?.data.isNullOrEmpty()) { + while(true){ + // Get weather for closest position + val weatherDataForCurrentPosition = if (location == null || weatherData?.data?.size == 1) weatherData?.data?.first() else { + val weatherDatas = weatherData?.data?.sortedBy { data -> + TurfMeasurement.distance( + Point.fromLngLat(location.lon, location.lat), + Point.fromLngLat(data.coords.lon, data.coords.lat), + TurfConstants.UNIT_METERS + ) + }!!.take(2) + + val location1 = weatherDatas[0] + val location2 = weatherDatas[1] + val distanceToLocation1 = TurfMeasurement.distance( + Point.fromLngLat(location.lon, location.lat), + Point.fromLngLat(location1.coords.lon, location1.coords.lat), + TurfConstants.UNIT_METERS + ) + val distanceToLocation2 = TurfMeasurement.distance( + Point.fromLngLat(location.lon, location.lat), + Point.fromLngLat(location2.coords.lon, location2.coords.lat), + TurfConstants.UNIT_METERS + ) + val lerpFactor = (distanceToLocation1 / (distanceToLocation1 + distanceToLocation2)).coerceIn(0.0, 1.0) + + val interpolatedWeatherData = lerpWeather( + start = location1.current, + end = location2.current, + factor = lerpFactor + ) + + val interpolatedForecasts = location1.forecasts?.mapIndexed { index, forecast -> + val forecast2 = location2.forecasts?.get(index) ?: error("Mismatched forecast lengths") + val interpolatedForecast = lerpWeather( + start = forecast, + end = forecast2, + factor = lerpFactor + ) + interpolatedForecast + } + + WeatherDataForLocation( + coords = location, + current = interpolatedWeatherData, + forecasts = interpolatedForecasts + ) + } + + if (weatherDataForCurrentPosition != null) { + emit(lerpWeatherTime( + weatherDataForCurrentPosition.forecasts, + weatherDataForCurrentPosition.current + )) + } else { + emit(null) + } + + delay(1.minutes) + } + } else { + emit(null) + } } } } diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/HeadingFlow.kt b/app/src/main/kotlin/de/timklge/karooheadwind/HeadingFlow.kt index 50f9885..4e9bf6b 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/HeadingFlow.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/HeadingFlow.kt @@ -3,8 +3,8 @@ package de.timklge.karooheadwind import android.content.Context import android.util.Log import de.timklge.karooheadwind.datatypes.GpsCoordinates +import de.timklge.karooheadwind.util.signedAngleDifference import io.hammerhead.karooext.KarooSystemService -import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine @@ -13,10 +13,8 @@ import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.map - sealed class HeadingResponse { data object NoGps: HeadingResponse() data object NoWeatherData: HeadingResponse() @@ -24,14 +22,14 @@ sealed class HeadingResponse { } fun KarooSystemService.getRelativeHeadingFlow(context: Context): Flow { - val currentWeatherData = context.streamCurrentWeatherData() + val currentWeatherData = context.streamCurrentWeatherData(this) return getHeadingFlow(context) .combine(currentWeatherData) { bearing, data -> bearing to data } .map { (bearing, data) -> when { - bearing is HeadingResponse.Value && data.isNotEmpty() -> { - val windBearing = data.first().data.current.windDirection + 180 + bearing is HeadingResponse.Value && data != null -> { + val windBearing = data.windDirection + 180 val diff = signedAngleDifference(bearing.diff, windBearing) Log.d(KarooHeadwindExtension.TAG, "Wind bearing: Heading $bearing vs wind $windBearing => $diff") @@ -39,7 +37,7 @@ fun KarooSystemService.getRelativeHeadingFlow(context: Context): Flow HeadingResponse.NoGps - bearing is HeadingResponse.NoWeatherData || data.isEmpty() -> HeadingResponse.NoWeatherData + bearing is HeadingResponse.NoWeatherData || data == null -> HeadingResponse.NoWeatherData else -> bearing } } diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/KarooHeadwindExtension.kt b/app/src/main/kotlin/de/timklge/karooheadwind/KarooHeadwindExtension.kt index 46be4db..d1fe708 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/KarooHeadwindExtension.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/KarooHeadwindExtension.kt @@ -1,7 +1,6 @@ package de.timklge.karooheadwind import android.util.Log -import androidx.compose.ui.util.fastZip import com.mapbox.geojson.LineString import com.mapbox.turf.TurfConstants import com.mapbox.turf.TurfMeasurement @@ -26,6 +25,7 @@ import de.timklge.karooheadwind.datatypes.WindDirectionDataType import de.timklge.karooheadwind.datatypes.WindForecastDataType import de.timklge.karooheadwind.datatypes.WindGustsDataType import de.timklge.karooheadwind.datatypes.WindSpeedDataType +import de.timklge.karooheadwind.weatherprovider.WeatherProviderFactory import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.extension.KarooExtension import io.hammerhead.karooext.models.UserProfile @@ -45,11 +45,9 @@ import kotlinx.coroutines.flow.retry import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.launch import kotlinx.coroutines.time.debounce -import kotlinx.coroutines.withContext import java.time.Duration import java.time.LocalDateTime import java.time.temporal.ChronoUnit -import java.util.zip.GZIPInputStream import kotlin.math.absoluteValue import kotlin.math.roundToInt import kotlin.time.Duration.Companion.hours @@ -73,15 +71,15 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", BuildConfig.VERS WeatherDataType(karooSystem, applicationContext), WeatherForecastDataType(karooSystem), HeadwindSpeedDataType(karooSystem, applicationContext), - RelativeHumidityDataType(applicationContext), - CloudCoverDataType(applicationContext), - WindGustsDataType(applicationContext), - WindSpeedDataType(applicationContext), - TemperatureDataType(applicationContext), + RelativeHumidityDataType(karooSystem, applicationContext), + CloudCoverDataType(karooSystem, applicationContext), + WindGustsDataType(karooSystem, applicationContext), + WindSpeedDataType(karooSystem, applicationContext), + TemperatureDataType(karooSystem, applicationContext), WindDirectionDataType(karooSystem, applicationContext), - PrecipitationDataType(applicationContext), - SurfacePressureDataType(applicationContext), - SealevelPressureDataType(applicationContext), + PrecipitationDataType(karooSystem, applicationContext), + SurfacePressureDataType(karooSystem, applicationContext), + SealevelPressureDataType(karooSystem, applicationContext), UserWindSpeedDataType(karooSystem, applicationContext), TemperatureForecastDataType(karooSystem), PrecipitationForecastDataType(karooSystem), @@ -121,10 +119,9 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", BuildConfig.VERS } .debounce(Duration.ofSeconds(5)) - var requestedGpsCoordinates: List = mutableListOf() + var requestedGpsCoordinates: List = emptyList() - val settingsStream = streamSettings(karooSystem) - .filter { it.welcomeDialogAccepted } + val settingsStream = streamSettings(karooSystem).filter { it.welcomeDialogAccepted } data class StreamData(val settings: HeadwindSettings, val gps: GpsCoordinates?, val profile: UserProfile?, val upcomingRoute: UpcomingRoute?) data class StreamDataIdentity(val settings: HeadwindSettings, val gpsLat: Double?, val gpsLon: Double?, val profile: UserProfile?, val routePolyline: LineString?) @@ -211,50 +208,29 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", BuildConfig.VERS requestedGpsCoordinates = mutableListOf(gps) } - val response = karooSystem.makeOpenMeteoHttpRequest(requestedGpsCoordinates, settings, profile) - if (response.error != null){ - try { - - val stats = lastKnownStats.copy(failedWeatherRequest = System.currentTimeMillis()) - launch { saveStats(this@KarooHeadwindExtension, stats) } - } catch(e: Exception){ - Log.e(TAG, "Failed to write stats", e) - } - error("HTTP request failed: ${response.error}") - } else { - try { - val responseBody = response.body?.let { String(it) } - var weatherDataProvider: WeatherDataProvider? = null - + val response = try { + WeatherProviderFactory.makeWeatherRequest(karooSystem, requestedGpsCoordinates, settings, profile) + } catch(e: Throwable){ + val stats = lastKnownStats.copy(failedWeatherRequest = System.currentTimeMillis()) + launch { try { - if (responseBody != null) { - if (responseBody.trim().startsWith("[")) { - val responseArray = - jsonWithUnknownKeys.decodeFromString>( - responseBody - ) - weatherDataProvider = responseArray.firstOrNull()?.provider - } else { - val responseObject = - jsonWithUnknownKeys.decodeFromString( - responseBody - ) - weatherDataProvider = responseObject.provider - } - } - } catch (e: Exception) { - Log.e(TAG, "Error decoding provider", e) + saveStats(this@KarooHeadwindExtension, stats) + } catch(e: Exception){ + Log.e(TAG, "Failed to write stats", e) } - - val stats = lastKnownStats.copy( - lastSuccessfulWeatherRequest = System.currentTimeMillis(), - lastSuccessfulWeatherPosition = gps, - lastSuccessfulWeatherProvider = weatherDataProvider - ) - launch { saveStats(this@KarooHeadwindExtension, stats) } - } catch(e: Exception){ - Log.e(TAG, "Failed to write stats", e) } + throw e + } + + try { + val stats = lastKnownStats.copy( + lastSuccessfulWeatherRequest = System.currentTimeMillis(), + lastSuccessfulWeatherPosition = gps, + lastSuccessfulWeatherProvider = response.provider + ) + launch { saveStats(this@KarooHeadwindExtension, stats) } + } catch(e: Exception){ + Log.e(TAG, "Failed to write stats", e) } response @@ -263,30 +239,8 @@ class KarooHeadwindExtension : KarooExtension("karoo-headwind", BuildConfig.VERS delay(1.minutes); true }.collect { response -> try { - val inputStream = java.io.ByteArrayInputStream(response.body ?: ByteArray(0)) - val lowercaseHeaders = response.headers.map { (k: String, v: String) -> k.lowercase() to v.lowercase() }.toMap() - val isGzippedResponse = lowercaseHeaders["content-encoding"]?.contains("gzip") == true - val responseString = if(isGzippedResponse){ - val gzipStream = withContext(Dispatchers.IO) { GZIPInputStream(inputStream) } - gzipStream.use { stream -> String(stream.readBytes()) } - } else { - inputStream.use { stream -> String(stream.readBytes()) } - } - if (requestedGpsCoordinates.size == 1){ - val weatherData = jsonWithUnknownKeys.decodeFromString(responseString) - val data = WeatherDataResponse(weatherData, requestedGpsCoordinates.single()) - - saveCurrentData(applicationContext, listOf(data)) - - Log.d(TAG, "Got updated weather info: $data") - } else { - val weatherData = jsonWithUnknownKeys.decodeFromString>(responseString) - val data = weatherData.fastZip(requestedGpsCoordinates) { weather, gps -> WeatherDataResponse(weather, gps) } - - saveCurrentData(applicationContext, data) - - Log.d(TAG, "Got updated weather info: $data") - } + saveCurrentData(applicationContext, response) + Log.d(TAG, "Got updated weather info: $response") saveWidgetSettings(applicationContext, HeadwindWidgetSettings(currentForecastHourOffset = 0)) } catch(e: Exception){ diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/OpenMeteo.kt b/app/src/main/kotlin/de/timklge/karooheadwind/OpenMeteo.kt deleted file mode 100644 index ba44d33..0000000 --- a/app/src/main/kotlin/de/timklge/karooheadwind/OpenMeteo.kt +++ /dev/null @@ -1,85 +0,0 @@ -package de.timklge.karooheadwind - -import android.util.Log -import de.timklge.karooheadwind.datatypes.GpsCoordinates -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 java.util.Locale -import kotlin.time.Duration.Companion.seconds - -@OptIn(FlowPreview::class) -suspend fun KarooSystemService.originalOpenMeteoRequest(gpsCoordinates: List, 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,pressure_msl,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 lats = gpsCoordinates.joinToString(",") { String.format(Locale.US, "%.6f", it.lat) } - val lons = gpsCoordinates.joinToString(",") { String.format(Locale.US, "%.6f", it.lon) } - val url = "https://api.open-meteo.com/v1/forecast?latitude=${lats}&longitude=${lons}¤t=surface_pressure,pressure_msl,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, - headers = mapOf("User-Agent" to KarooHeadwindExtension.TAG, "Accept-Encoding" to "gzip"), - ), - onEvent = { event: OnHttpResponse -> - if (event.state is HttpResponseState.Complete){ - Log.d(KarooHeadwindExtension.TAG, "Http response received") - trySend(event.state as HttpResponseState.Complete) - close() - } - }, - onError = { err -> - Log.d(KarooHeadwindExtension.TAG, "Http error: $err") - close(RuntimeException(err)) - }) - awaitClose { - removeConsumer(listenerId) - } - }.timeout(30.seconds).catch { e: Throwable -> - if (e is TimeoutCancellationException){ - emit(HttpResponseState.Complete(500, mapOf(), null, "Timeout")) - } else { - throw e - } - }.single() -} - -@OptIn(FlowPreview::class) -suspend fun KarooSystemService.makeOpenMeteoHttpRequest( - gpsCoordinates: List, - settings: HeadwindSettings, - profile: UserProfile? -): HttpResponseState.Complete { - val provider = WeatherProviderFactory.getProvider(settings) - val response = provider.getWeatherData(this, gpsCoordinates, settings, profile) - - if (response.error != null) { - if (provider is OpenWeatherMapProvider) { - WeatherProviderFactory.handleOpenWeatherMapFailure() - } - } else { - - if (provider is OpenWeatherMapProvider) { - WeatherProviderFactory.resetOpenWeatherMapFailures() - } else if (provider is OpenMeteoProvider) { - WeatherProviderFactory.handleOpenMeteoSuccess() - } - } - - return response -} \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/OpenMeteoData.kt b/app/src/main/kotlin/de/timklge/karooheadwind/OpenMeteoData.kt deleted file mode 100644 index 4f61b8b..0000000 --- a/app/src/main/kotlin/de/timklge/karooheadwind/OpenMeteoData.kt +++ /dev/null @@ -1,64 +0,0 @@ -package de.timklge.karooheadwind - -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable - -@Serializable -data class OpenMeteoData( - val time: Long, val interval: Int, - @SerialName("temperature_2m") val temperature: Double, - @SerialName("relative_humidity_2m") val relativeHumidity: Int, - @SerialName("precipitation") val precipitation: Double, - @SerialName("cloud_cover") val cloudCover: Int, - @SerialName("surface_pressure") val surfacePressure: Double, - @SerialName("pressure_msl") val sealevelPressure: Double? = null, - @SerialName("wind_speed_10m") val windSpeed: Double, - @SerialName("wind_direction_10m") val windDirection: Double, - @SerialName("wind_gusts_10m") val windGusts: Double, - @SerialName("weather_code") val weatherCode: Int, -) - -@Serializable -data class OpenMeteoForecastData( - @SerialName("time") val time: List, - @SerialName("temperature_2m") val temperature: List, - @SerialName("precipitation_probability") val precipitationProbability: List, - @SerialName("precipitation") val precipitation: List, - @SerialName("weather_code") val weatherCode: List, - @SerialName("wind_speed_10m") val windSpeed: List, - @SerialName("wind_direction_10m") val windDirection: List, - @SerialName("wind_gusts_10m") val windGusts: List, -) - -enum class WeatherInterpretation { - CLEAR, CLOUDY, RAINY, SNOWY, DRIZZLE, THUNDERSTORM, UNKNOWN; - - companion object { - // WMO weather interpretation codes (WW) - fun fromWeatherCode(code: Int): WeatherInterpretation { - return when(code){ - 0 -> CLEAR - 1, 2, 3 -> CLOUDY - 45, 48, 61, 63, 65, 66, 67, 80, 81, 82 -> RAINY - 71, 73, 75, 77, 85, 86 -> SNOWY - 51, 53, 55, 56, 57 -> DRIZZLE - 95, 96, 99 -> THUNDERSTORM - else -> UNKNOWN - } - } - - fun getKnownWeatherCodes(): Set = setOf(0, 1, 2, 3, 45, 48, 61, 63, 65, 66, 67, 80, 81, 82, 71, 73, 75, 77, 85, 86, 51, 53, 55, 56, 57, 95, 96, 99) - } -} - -@Serializable -data class OpenMeteoCurrentWeatherResponse( - val current: OpenMeteoData, - val latitude: Double, - val longitude: Double, - val timezone: String, - val elevation: Double, - @SerialName("utc_offset_seconds") val utfOffsetSeconds: Int, - @SerialName("hourly") val forecastData: OpenMeteoForecastData?, - val provider: WeatherDataProvider? = null -) \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/OpenMeteoProvider.kt b/app/src/main/kotlin/de/timklge/karooheadwind/OpenMeteoProvider.kt deleted file mode 100644 index 8d7e509..0000000 --- a/app/src/main/kotlin/de/timklge/karooheadwind/OpenMeteoProvider.kt +++ /dev/null @@ -1,39 +0,0 @@ -package de.timklge.karooheadwind - -import android.util.Log -import de.timklge.karooheadwind.datatypes.GpsCoordinates -import io.hammerhead.karooext.KarooSystemService -import io.hammerhead.karooext.models.HttpResponseState -import io.hammerhead.karooext.models.UserProfile - -class OpenMeteoProvider : WeatherProvider { - override suspend fun getWeatherData( - karooSystem: KarooSystemService, - coordinates: List, - settings: HeadwindSettings, - profile: UserProfile? - ): HttpResponseState.Complete { - val response = karooSystem.originalOpenMeteoRequest(coordinates, settings, profile) - - if (response.error != null || response.body == null) { - return response - } - - try { - - val responseBody = response.body?.let { String(it) } - ?: return response - - val weatherData = jsonWithUnknownKeys.decodeFromString(responseBody) - - - val updatedData = weatherData.copy(provider = WeatherDataProvider.OPEN_METEO) - val updatedJson = jsonWithUnknownKeys.encodeToString(updatedData) - - return response.copy(body = updatedJson.toByteArray()) - } catch (e: Exception) { - Log.e(KarooHeadwindExtension.TAG, "Error processing OpenMeteo response", e) - return response - } - } -} \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/OpenWeatherMapProvider.kt b/app/src/main/kotlin/de/timklge/karooheadwind/OpenWeatherMapProvider.kt deleted file mode 100644 index f0388ce..0000000 --- a/app/src/main/kotlin/de/timklge/karooheadwind/OpenWeatherMapProvider.kt +++ /dev/null @@ -1,248 +0,0 @@ -package de.timklge.karooheadwind - -import android.util.Log -import de.timklge.karooheadwind.datatypes.GpsCoordinates -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 kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlin.time.Duration.Companion.seconds - -@Serializable -data class OneCallResponse( - val lat: Double, - val lon: Double, - val timezone: String, - @SerialName("timezone_offset") val timezoneOffset: Int, - val current: CurrentWeather, - val hourly: List -) - -@Serializable -data class CurrentWeather( - val dt: Long, - val sunrise: Long, - val sunset: Long, - val temp: Double, - val feels_like: Double, - val pressure: Int, - val humidity: Int, - val clouds: Int, - val visibility: Int, - val wind_speed: Double, - val wind_deg: Int, - val wind_gust: Double? = null, - val rain: Rain? = null, - val snow: Snow? = null, - val weather: List -) - -@Serializable -data class HourlyForecast( - val dt: Long, - val temp: Double, - val feels_like: Double, - val pressure: Int, - val humidity: Int, - val clouds: Int, - val visibility: Int, - val wind_speed: Double, - val wind_deg: Int, - val wind_gust: Double? = null, - val pop: Double, - val rain: Rain? = null, - val snow: Snow? = null, - val weather: List -) - - -@Serializable -data class Weather( - val id: Int, - val main: String, - val description: String, - val icon: String -) - -@Serializable -data class Rain( - @SerialName("1h") val h1: Double = 0.0, - @SerialName("3h") val h3: Double = 0.0 -) - -@Serializable -data class Snow( - @SerialName("1h") val h1: Double = 0.0, - @SerialName("3h") val h3: Double = 0.0 -) - - -class OpenWeatherMapProvider(private val apiKey: String) : WeatherProvider { - override suspend fun getWeatherData( - service: KarooSystemService, - coordinates: List, - settings: HeadwindSettings, - profile: UserProfile? - ): HttpResponseState.Complete { - - val response = makeOpenWeatherMapRequest(service, coordinates, apiKey) - - if (response.error != null || response.body == null) { - return response - } - - try { - val responseBody = response.body?.let { String(it) } - ?: throw Exception("Null response from OpenWeatherMap") - - - if (coordinates.size > 1) { - val responses = mutableListOf() - - - val oneCallResponse = jsonWithUnknownKeys.decodeFromString(responseBody) - responses.add(convertToOpenMeteoFormat(oneCallResponse)) - - val finalBody = jsonWithUnknownKeys.encodeToString(responses) - return HttpResponseState.Complete( - statusCode = response.statusCode, - headers = response.headers, - body = finalBody.toByteArray(), - error = null - ) - } else { - - val oneCallResponse = jsonWithUnknownKeys.decodeFromString(responseBody) - val convertedResponse = convertToOpenMeteoFormat(oneCallResponse) - - val finalBody = jsonWithUnknownKeys.encodeToString(convertedResponse) - return HttpResponseState.Complete( - statusCode = response.statusCode, - headers = response.headers, - body = finalBody.toByteArray(), - error = null - ) - } - } catch (e: Exception) { - Log.e(KarooHeadwindExtension.TAG, "Error OpenWeatherMap answer processing", e) - return HttpResponseState.Complete( - statusCode = 500, - headers = mapOf(), - body = null, - error = "Error processing data: ${e.message}" - ) - } - } - - private fun convertToOpenMeteoFormat(oneCallResponse: OneCallResponse): OpenMeteoCurrentWeatherResponse { - - val current = OpenMeteoData( - time = oneCallResponse.current.dt, - interval = 3600, - temperature = oneCallResponse.current.temp, - relativeHumidity = oneCallResponse.current.humidity, - precipitation = oneCallResponse.current.rain?.h1 ?: 0.0, - cloudCover = oneCallResponse.current.clouds, - surfacePressure = oneCallResponse.current.pressure.toDouble(), - sealevelPressure = oneCallResponse.current.pressure.toDouble(), - windSpeed = oneCallResponse.current.wind_speed, - windDirection = oneCallResponse.current.wind_deg.toDouble(), - windGusts = oneCallResponse.current.wind_gust ?: oneCallResponse.current.wind_speed, - weatherCode = convertWeatherCodeToOpenMeteo(oneCallResponse.current.weather.firstOrNull()?.id ?: 800) - ) - - - val forecastHours = minOf(12, oneCallResponse.hourly.size) - val hourlyForecasts = oneCallResponse.hourly.take(forecastHours) - - val forecastData = OpenMeteoForecastData( - time = hourlyForecasts.map { it.dt }, - temperature = hourlyForecasts.map { it.temp }, - precipitationProbability = hourlyForecasts.map { (it.pop * 100).toInt() }, - precipitation = hourlyForecasts.map { it.rain?.h1 ?: 0.0 }, - weatherCode = hourlyForecasts.map { convertWeatherCodeToOpenMeteo(it.weather.firstOrNull()?.id ?: 800) }, - windSpeed = hourlyForecasts.map { it.wind_speed }, - windDirection = hourlyForecasts.map { it.wind_deg.toDouble() }, - windGusts = hourlyForecasts.map { it.wind_gust ?: it.wind_speed } - ) - - return OpenMeteoCurrentWeatherResponse( - current = current, - latitude = oneCallResponse.lat, - longitude = oneCallResponse.lon, - timezone = oneCallResponse.timezone, - elevation = 0.0, - utfOffsetSeconds = oneCallResponse.timezoneOffset, - forecastData = forecastData, - provider = WeatherDataProvider.OPEN_WEATHER_MAP - ) - } - - private fun convertWeatherCodeToOpenMeteo(owmCode: Int): Int { - // Mapping OpenWeatherMap to WMO OpenMeteo - return when (owmCode) { - in 200..299 -> 95 // Thunderstorm - in 300..399 -> 51 // Drizzle - in 500..599 -> 61 // Rain - in 600..699 -> 71 // Snow - 800 -> 0 // Clear - in 801..804 -> 1 // Cloudy - else -> 0 - } - } - - @OptIn(FlowPreview::class) - private suspend fun makeOpenWeatherMapRequest( - service: KarooSystemService, - coordinates: List, - apiKey: String - ): HttpResponseState.Complete { - return callbackFlow { - val coordinate = coordinates.first() - // URL API 3.0 with onecall endpoint - val url = "https://api.openweathermap.org/data/3.0/onecall?lat=${coordinate.lat}&lon=${coordinate.lon}" + - "&appid=$apiKey&units=metric&exclude=minutely,daily,alerts" - - Log.d(KarooHeadwindExtension.TAG, "Http request to OpenWeatherMap API 3.0: $url") - - val listenerId = service.addConsumer( - OnHttpResponse.MakeHttpRequest( - "GET", - url, - waitForConnection = false, - headers = mapOf("User-Agent" to KarooHeadwindExtension.TAG) - ), - onEvent = { event: OnHttpResponse -> - if (event.state is HttpResponseState.Complete) { - Log.d(KarooHeadwindExtension.TAG, "Http response received from OpenWeatherMap") - trySend(event.state as HttpResponseState.Complete) - close() - } - }, - onError = { err -> - Log.e(KarooHeadwindExtension.TAG, "Http error: $err") - close(RuntimeException(err)) - } - ) - - awaitClose { - service.removeConsumer(listenerId) - } - }.timeout(30.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/WeatherProviderFactory.kt b/app/src/main/kotlin/de/timklge/karooheadwind/WeatherProviderFactory.kt deleted file mode 100644 index db4c3fe..0000000 --- a/app/src/main/kotlin/de/timklge/karooheadwind/WeatherProviderFactory.kt +++ /dev/null @@ -1,71 +0,0 @@ -package de.timklge.karooheadwind - -import android.util.Log -import java.time.LocalDate - -object WeatherProviderFactory { - private var openWeatherMapConsecutiveFailures = 0 - private var openWeatherMapTotalFailures = 0 - private var openMeteoSuccessfulAfterFailures = false - private var fallbackUntilDate: LocalDate? = null - - private const val MAX_FAILURES_BEFORE_TEMP_FALLBACK = 3 - private const val MAX_FAILURES_BEFORE_DAILY_FALLBACK = 20 - - fun getProvider(settings: HeadwindSettings): WeatherProvider { - val currentDate = LocalDate.now() - - - if (fallbackUntilDate != null && !currentDate.isAfter(fallbackUntilDate)) { - Log.d(KarooHeadwindExtension.TAG, "Using diary fallback OpenMeteo until $fallbackUntilDate") - return OpenMeteoProvider() - } - - - if (settings.weatherProvider == WeatherDataProvider.OPEN_WEATHER_MAP && - openWeatherMapConsecutiveFailures >= MAX_FAILURES_BEFORE_TEMP_FALLBACK) { - openWeatherMapConsecutiveFailures = 0 - Log.d(KarooHeadwindExtension.TAG, "Using temporary fallback OpenMeteo") - return OpenMeteoProvider() - } - - - return when (settings.weatherProvider) { - WeatherDataProvider.OPEN_METEO -> OpenMeteoProvider() - WeatherDataProvider.OPEN_WEATHER_MAP -> OpenWeatherMapProvider(settings.openWeatherMapApiKey) - } - } - - fun handleOpenWeatherMapFailure() { - openWeatherMapConsecutiveFailures++ - openWeatherMapTotalFailures++ - - Log.d(KarooHeadwindExtension.TAG, "OpenWeatherMap failed $openWeatherMapConsecutiveFailures times consecutive, $openWeatherMapTotalFailures total times") - - if (openWeatherMapTotalFailures >= MAX_FAILURES_BEFORE_DAILY_FALLBACK && openMeteoSuccessfulAfterFailures) { - fallbackUntilDate = LocalDate.now() - Log.d(KarooHeadwindExtension.TAG, "Activated daily fallback OpenMeteo until $fallbackUntilDate") - } - } - - fun handleOpenMeteoSuccess() { - openMeteoSuccessfulAfterFailures = openWeatherMapTotalFailures > 0 - } - - fun resetOpenWeatherMapFailures() { - openWeatherMapConsecutiveFailures = 0 - } - - fun resetAllFailures() { - openWeatherMapConsecutiveFailures = 0 - openWeatherMapTotalFailures = 0 - openMeteoSuccessfulAfterFailures = false - fallbackUntilDate = null - } -} - - -data class ProviderState( - val provider: WeatherDataProvider, - var consecutiveFailures: Int = 0 -) \ No newline at end of file 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 a9b575f..9f595cb 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/BaseDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/BaseDataType.kt @@ -3,8 +3,9 @@ package de.timklge.karooheadwind.datatypes import android.content.Context import android.util.Log import de.timklge.karooheadwind.KarooHeadwindExtension -import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse +import de.timklge.karooheadwind.weatherprovider.WeatherData import de.timklge.karooheadwind.streamCurrentWeatherData +import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.extension.DataTypeImpl import io.hammerhead.karooext.internal.Emitter import io.hammerhead.karooext.models.DataPoint @@ -16,22 +17,24 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch abstract class BaseDataType( + private val karooSystemService: KarooSystemService, private val applicationContext: Context, dataTypeId: String ) : DataTypeImpl("karoo-headwind", dataTypeId) { - abstract fun getValue(data: OpenMeteoCurrentWeatherResponse): Double + abstract fun getValue(data: WeatherData): Double? override fun startStream(emitter: Emitter) { Log.d(KarooHeadwindExtension.TAG, "start $dataTypeId stream") val job = CoroutineScope(Dispatchers.IO).launch { - val currentWeatherData = applicationContext.streamCurrentWeatherData() + val currentWeatherData = applicationContext.streamCurrentWeatherData(karooSystemService) currentWeatherData .filterNotNull() .collect { data -> - val value = data.firstOrNull()?.data?.let { w -> getValue(w) } + val value = getValue(data) Log.d(KarooHeadwindExtension.TAG, "$dataTypeId: $value") - if (value != null){ + + if (value != null) { emitter.onNext(StreamState.Streaming(DataPoint(dataTypeId, mapOf(DataType.Field.SINGLE to value)))) } else { emitter.onNext(StreamState.NotAvailable) diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/CloudCoverDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/CloudCoverDataType.kt index 829002b..f3ed860 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/CloudCoverDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/CloudCoverDataType.kt @@ -1,10 +1,11 @@ package de.timklge.karooheadwind.datatypes import android.content.Context -import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse +import de.timklge.karooheadwind.weatherprovider.WeatherData +import io.hammerhead.karooext.KarooSystemService -class CloudCoverDataType(context: Context) : BaseDataType(context, "cloudCover"){ - override fun getValue(data: OpenMeteoCurrentWeatherResponse): Double { - return data.current.cloudCover.toDouble() +class CloudCoverDataType(karooSystemService: KarooSystemService, context: Context) : BaseDataType(karooSystemService, context, "cloudCover"){ + override fun getValue(data: WeatherData): Double? { + return data.cloudCover } } \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/CycleHoursAction.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/CycleHoursAction.kt index 4677dbb..47f3103 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/CycleHoursAction.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/CycleHoursAction.kt @@ -7,7 +7,7 @@ import androidx.glance.action.ActionParameters import androidx.glance.appwidget.action.ActionCallback import de.timklge.karooheadwind.KarooHeadwindExtension import de.timklge.karooheadwind.saveWidgetSettings -import de.timklge.karooheadwind.streamCurrentWeatherData +import de.timklge.karooheadwind.streamCurrentForecastWeatherData import de.timklge.karooheadwind.streamWidgetSettings import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull @@ -21,13 +21,13 @@ class CycleHoursAction : ActionCallback { Log.d(KarooHeadwindExtension.TAG, "Cycling hours") val currentSettings = context.streamWidgetSettings().first() - val data = context.streamCurrentWeatherData().firstOrNull() + val forecastData = context.streamCurrentForecastWeatherData().firstOrNull() var hourOffset = currentSettings.currentForecastHourOffset + 3 - val requestedPositions = data?.size - val requestedHours = data?.firstOrNull()?.data?.forecastData?.weatherCode?.size + val requestedPositions = forecastData?.data?.size + val requestedHours = forecastData?.data?.firstOrNull()?.forecasts?.size - if (data == null || requestedHours == null || requestedPositions == null || hourOffset >= requestedHours || (requestedPositions > 1 && hourOffset >= requestedPositions)) { + if (forecastData == null || requestedHours == null || requestedPositions == null || hourOffset >= requestedHours || (requestedPositions in 2..hourOffset)) { hourOffset = 0 } diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/ForecastDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/ForecastDataType.kt index 67de11b..2203519 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/ForecastDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/ForecastDataType.kt @@ -25,16 +25,16 @@ import de.timklge.karooheadwind.HeadingResponse import de.timklge.karooheadwind.HeadwindSettings import de.timklge.karooheadwind.HeadwindWidgetSettings import de.timklge.karooheadwind.KarooHeadwindExtension -import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse -import de.timklge.karooheadwind.OpenMeteoData -import de.timklge.karooheadwind.OpenMeteoForecastData import de.timklge.karooheadwind.R import de.timklge.karooheadwind.TemperatureUnit import de.timklge.karooheadwind.UpcomingRoute -import de.timklge.karooheadwind.WeatherDataResponse -import de.timklge.karooheadwind.WeatherInterpretation +import de.timklge.karooheadwind.weatherprovider.WeatherData +import de.timklge.karooheadwind.weatherprovider.WeatherDataForLocation +import de.timklge.karooheadwind.WeatherDataProvider +import de.timklge.karooheadwind.weatherprovider.WeatherDataResponse +import de.timklge.karooheadwind.weatherprovider.WeatherInterpretation import de.timklge.karooheadwind.getHeadingFlow -import de.timklge.karooheadwind.streamCurrentWeatherData +import de.timklge.karooheadwind.streamCurrentForecastWeatherData import de.timklge.karooheadwind.streamSettings import de.timklge.karooheadwind.streamUpcomingRoute import de.timklge.karooheadwind.streamUserProfile @@ -84,7 +84,7 @@ abstract class ForecastDataType(private val karooSystem: KarooSystemService, typ val timeFormatter = DateTimeFormatter.ofPattern("HH:mm").withZone(ZoneId.systemDefault()) } - data class StreamData(val data: List?, val settings: SettingsAndProfile, + data class StreamData(val data: WeatherDataResponse?, val settings: SettingsAndProfile, val widgetSettings: HeadwindWidgetSettings? = null, val headingResponse: HeadingResponse? = null, val upcomingRoute: UpcomingRoute? = null) @@ -97,57 +97,63 @@ abstract class ForecastDataType(private val karooSystem: KarooSystemService, typ while (true) { val data = (0..<10).map { index -> val timeAtFullHour = Instant.now().truncatedTo(ChronoUnit.HOURS).epochSecond - val forecastTimes = (0..<12).map { timeAtFullHour + it * 60 * 60 } - val forecastTemperatures = (0..<12).map { 20.0 + (-20..20).random() } - val forecastPrecipitationPropability = (0..<12).map { (0..100).random() } - val forecastPrecipitation = (0..<12).map { 0.0 + (0..10).random() } - val forecastWeatherCodes = - (0..<12).map { WeatherInterpretation.getKnownWeatherCodes().random() } - val forecastWindSpeed = (0..<12).map { 0.0 + (0..10).random() } - val forecastWindDirection = (0..<12).map { 0.0 + (0..360).random() } - val forecastWindGusts = (0..<12).map { 0.0 + (0..10).random() } - val weatherData = OpenMeteoCurrentWeatherResponse( - OpenMeteoData( - Instant.now().epochSecond, - 0, - 20.0, - 50, - 3.0, - 0, - 1013.25, - 980.0, - 15.0, - 30.0, - 30.0, - WeatherInterpretation.getKnownWeatherCodes().random() - ), - 0.0, 0.0, "Europe/Berlin", 30.0, 0, - OpenMeteoForecastData( - forecastTimes, - forecastTemperatures, - forecastPrecipitationPropability, - forecastPrecipitation, - forecastWeatherCodes, - forecastWindSpeed, - forecastWindDirection, - forecastWindGusts + val weatherData = (0..<12).map { + val forecastTime = timeAtFullHour + it * 60 * 60 + val forecastTemperature = 20.0 + (-20..20).random() + val forecastPrecipitation = 0.0 + (0..10).random() + val forecastPrecipitationProbability = (0..100).random() + val forecastWeatherCode = WeatherInterpretation.getKnownWeatherCodes().random() + val forecastWindSpeed = 0.0 + (0..10).random() + val forecastWindDirection = 0.0 + (0..360).random() + val forecastWindGusts = 0.0 + (0..10).random() + WeatherData( + time = forecastTime, + temperature = forecastTemperature, + relativeHumidity = 20.0, + precipitation = forecastPrecipitation, + cloudCover = 3.0, + sealevelPressure = 1013.25, + surfacePressure = 1013.25, + precipitationProbability = forecastPrecipitationProbability.toDouble(), + windSpeed = forecastWindSpeed, + windDirection = forecastWindDirection, + windGusts = forecastWindGusts, + weatherCode = forecastWeatherCode, + isForecast = true ) - ) + } val distancePerHour = settingsAndProfile?.settings?.getForecastMetersPerHour(settingsAndProfile.isImperial) ?.toDouble() ?: 0.0 - val gpsCoords = - GpsCoordinates(0.0, 0.0, distanceAlongRoute = index * distancePerHour) - WeatherDataResponse(weatherData, gpsCoords) + WeatherDataForLocation( + current = WeatherData( + time = timeAtFullHour, + temperature = 20.0, + relativeHumidity = 20.0, + precipitation = 0.0, + cloudCover = 3.0, + sealevelPressure = 1013.25, + surfacePressure = 1013.25, + windSpeed = 5.0, + windDirection = 180.0, + windGusts = 10.0, + weatherCode = WeatherInterpretation.getKnownWeatherCodes().random(), + isForecast = false + ), + coords = GpsCoordinates(0.0, 0.0, distanceAlongRoute = index * distancePerHour), + timezone = "UTC", + elevation = null, + forecasts = weatherData + ) } emit( StreamData( - data, + WeatherDataResponse(provider = WeatherDataProvider.OPEN_METEO, data = data), SettingsAndProfile( HeadwindSettings(), settingsAndProfile?.isImperial == true, @@ -182,7 +188,7 @@ abstract class ForecastDataType(private val karooSystem: KarooSystemService, typ previewFlow(settingsAndProfileStream) } else { combine( - context.streamCurrentWeatherData(), + context.streamCurrentForecastWeatherData(), settingsAndProfileStream, context.streamWidgetSettings(), karooSystem.getHeadingFlow(context), @@ -204,7 +210,7 @@ abstract class ForecastDataType(private val karooSystem: KarooSystemService, typ dataFlow.collect { (allData, settingsAndProfile, widgetSettings, headingResponse, upcomingRoute) -> Log.d(KarooHeadwindExtension.TAG, "Updating weather forecast view") - if (allData.isNullOrEmpty()){ + if (allData?.data.isNullOrEmpty()){ emitter.updateView( getErrorWidget( glance, @@ -223,42 +229,29 @@ abstract class ForecastDataType(private val karooSystem: KarooSystemService, typ Row(modifier = modifier, horizontalAlignment = Alignment.Horizontal.Start) { val hourOffset = widgetSettings?.currentForecastHourOffset ?: 0 - val positionOffset = if (allData.size == 1) 0 else hourOffset + val positionOffset = if (allData?.data?.size == 1) 0 else hourOffset var previousDate: String? = let { - val unixTime = - allData.getOrNull(positionOffset)?.data?.forecastData?.time?.getOrNull( - hourOffset - ) + val unixTime = allData?.data?.getOrNull(positionOffset)?.forecasts?.getOrNull(hourOffset)?.time val formattedDate = unixTime?.let { - getShortDateFormatter().format( - Instant.ofEpochSecond(unixTime) - ) + getShortDateFormatter().format(Instant.ofEpochSecond(unixTime)) } formattedDate } for (baseIndex in hourOffset..hourOffset + 2) { - val positionIndex = if (allData.size == 1) 0 else baseIndex + val positionIndex = if (allData?.data?.size == 1) 0 else baseIndex - if (allData.getOrNull(positionIndex) == null) break - if (baseIndex >= (allData.getOrNull(positionOffset)?.data?.forecastData?.weatherCode?.size - ?: 0) - ) { - break + if (allData?.data?.getOrNull(positionIndex) == null) break + if (baseIndex >= (allData.data.getOrNull(positionOffset)?.forecasts?.size ?: 0)) break + + val data = allData.data.getOrNull(positionIndex) + val distanceAlongRoute = allData.data.getOrNull(positionIndex)?.coords?.distanceAlongRoute + val position = allData.data.getOrNull(positionIndex)?.coords?.let { + "${(it.distanceAlongRoute?.div(1000.0))?.toInt()} at ${it.lat}, ${it.lon}" } - val data = allData.getOrNull(positionIndex)?.data - val distanceAlongRoute = - allData.getOrNull(positionIndex)?.requestedPosition?.distanceAlongRoute - val position = - allData.getOrNull(positionIndex)?.requestedPosition?.let { - "${ - (it.distanceAlongRoute?.div(1000.0))?.toInt() - } at ${it.lat}, ${it.lon}" - } - if (baseIndex > hourOffset) { Spacer( modifier = GlanceModifier.fillMaxHeight().background( @@ -280,8 +273,7 @@ abstract class ForecastDataType(private val karooSystem: KarooSystemService, typ val isCurrent = baseIndex == 0 && positionIndex == 0 if (isCurrent && data?.current != null) { - val interpretation = - WeatherInterpretation.fromWeatherCode(data.current.weatherCode) + val interpretation = WeatherInterpretation.fromWeatherCode(data.current.weatherCode) val unixTime = data.current.time val formattedTime = timeFormatter.format(Instant.ofEpochSecond(unixTime)) @@ -307,32 +299,22 @@ abstract class ForecastDataType(private val karooSystem: KarooSystemService, typ previousDate = formattedDate } else { - val interpretation = WeatherInterpretation.fromWeatherCode( - data?.forecastData?.weatherCode?.get(baseIndex) ?: 0 - ) - val unixTime = data?.forecastData?.time?.get(baseIndex) ?: 0 - val formattedTime = - timeFormatter.format(Instant.ofEpochSecond(unixTime)) - val formattedDate = - getShortDateFormatter().format(Instant.ofEpochSecond(unixTime)) + val weatherData = data?.forecasts?.getOrNull(baseIndex) + val interpretation = WeatherInterpretation.fromWeatherCode(weatherData?.weatherCode ?: 0) + val unixTime = data?.forecasts?.getOrNull(baseIndex)?.time ?: 0 + val formattedTime = timeFormatter.format(Instant.ofEpochSecond(unixTime)) + val formattedDate = getShortDateFormatter().format(Instant.ofEpochSecond(unixTime)) val hasNewDate = formattedDate != previousDate || baseIndex == 0 RenderWidget( arrowBitmap = baseBitmap, current = interpretation, - windBearing = data?.forecastData?.windDirection?.get(baseIndex) - ?.roundToInt() ?: 0, - windSpeed = data?.forecastData?.windSpeed?.get(baseIndex) - ?.roundToInt() ?: 0, - windGusts = data?.forecastData?.windGusts?.get(baseIndex) - ?.roundToInt() ?: 0, - precipitation = data?.forecastData?.precipitation?.get(baseIndex) - ?: 0.0, - precipitationProbability = data?.forecastData?.precipitationProbability?.get( - baseIndex - ) ?: 0, - temperature = data?.forecastData?.temperature?.get(baseIndex) - ?.roundToInt() ?: 0, + windBearing = weatherData?.windDirection?.roundToInt() ?: 0, + windSpeed = weatherData?.windSpeed?.roundToInt() ?: 0, + windGusts = weatherData?.windGusts?.roundToInt() ?: 0, + precipitation = weatherData?.precipitation ?: 0.0, + precipitationProbability = weatherData?.precipitationProbability?.toInt(), + temperature = weatherData?.temperature?.roundToInt() ?: 0, temperatureUnit = if (settingsAndProfile.isImperialTemperature) TemperatureUnit.FAHRENHEIT else TemperatureUnit.CELSIUS, timeLabel = formattedTime, dateLabel = if (hasNewDate) formattedDate else null, diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/GraphicalForecast.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/GraphicalForecast.kt index 9755ae7..3d081af 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/GraphicalForecast.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/GraphicalForecast.kt @@ -16,17 +16,15 @@ import androidx.glance.layout.Column import androidx.glance.layout.ContentScale import androidx.glance.layout.Row import androidx.glance.layout.fillMaxHeight -import androidx.glance.layout.fillMaxWidth import androidx.glance.layout.padding import androidx.glance.layout.width import androidx.glance.layout.wrapContentWidth import androidx.glance.text.FontFamily import androidx.glance.text.FontWeight import androidx.glance.text.Text -import androidx.glance.text.TextAlign import androidx.glance.text.TextStyle import de.timklge.karooheadwind.TemperatureUnit -import de.timklge.karooheadwind.WeatherInterpretation +import de.timklge.karooheadwind.weatherprovider.WeatherInterpretation import io.hammerhead.karooext.KarooSystemService import kotlin.math.absoluteValue 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 ac2b6e2..845fce8 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindDirectionDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindDirectionDataType.kt @@ -42,8 +42,8 @@ class HeadwindDirectionDataType( private fun streamValues(): Flow = flow { karooSystem.getRelativeHeadingFlow(applicationContext) - .combine(applicationContext.streamCurrentWeatherData()) { headingResponse, data -> - StreamData(headingResponse, data.firstOrNull()?.data?.current?.windDirection, data.firstOrNull()?.data?.current?.windSpeed) + .combine(applicationContext.streamCurrentWeatherData(karooSystem)) { headingResponse, data -> + StreamData(headingResponse, data?.windDirection, data?.windSpeed) } .combine(applicationContext.streamSettings(karooSystem)) { data, settings -> data.copy(settings = settings) } .collect { streamData -> 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 6e19dbb..5ebc4ee 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindSpeedDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/HeadwindSpeedDataType.kt @@ -3,7 +3,7 @@ package de.timklge.karooheadwind.datatypes import android.content.Context import de.timklge.karooheadwind.HeadingResponse import de.timklge.karooheadwind.HeadwindSettings -import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse +import de.timklge.karooheadwind.weatherprovider.WeatherData import de.timklge.karooheadwind.getRelativeHeadingFlow import de.timklge.karooheadwind.streamCurrentWeatherData import de.timklge.karooheadwind.streamSettings @@ -16,7 +16,6 @@ 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 @@ -24,18 +23,17 @@ class HeadwindSpeedDataType( private val karooSystem: KarooSystemService, private val context: Context) : DataTypeImpl("karoo-headwind", "headwindSpeed"){ - data class StreamData(val headingResponse: HeadingResponse, val weatherResponse: OpenMeteoCurrentWeatherResponse?, val settings: HeadwindSettings) + data class StreamData(val headingResponse: HeadingResponse, val weatherData: WeatherData?, 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.streamCurrentWeatherData(karooSystem)) { value, data -> value to data } .combine(context.streamSettings(karooSystem)) { (value, data), settings -> - StreamData(value, data.firstOrNull()?.data, settings) + StreamData(value, data, settings) } - .filter { it.weatherResponse != null } .collect { streamData -> - val windSpeed = streamData.weatherResponse?.current?.windSpeed ?: 0.0 + val windSpeed = streamData.weatherData?.windSpeed ?: 0.0 val windDirection = (streamData.headingResponse as? HeadingResponse.Value)?.diff ?: 0.0 val headwindSpeed = cos( (windDirection + 180) * Math.PI / 180.0) * windSpeed diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/PrecipitationDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/PrecipitationDataType.kt index 4342715..c983104 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/PrecipitationDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/PrecipitationDataType.kt @@ -1,10 +1,11 @@ package de.timklge.karooheadwind.datatypes import android.content.Context -import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse +import de.timklge.karooheadwind.weatherprovider.WeatherData +import io.hammerhead.karooext.KarooSystemService -class PrecipitationDataType(context: Context) : BaseDataType(context, "precipitation"){ - override fun getValue(data: OpenMeteoCurrentWeatherResponse): Double { - return data.current.precipitation +class PrecipitationDataType(karooSystemService: KarooSystemService, context: Context) : BaseDataType(karooSystemService, context, "precipitation"){ + override fun getValue(data: WeatherData): Double { + return data.precipitation } } \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/PrecipitationForecastDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/PrecipitationForecastDataType.kt index 9ea3f5b..fe24e2e 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/PrecipitationForecastDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/PrecipitationForecastDataType.kt @@ -21,7 +21,7 @@ import androidx.glance.text.Text import androidx.glance.text.TextAlign import androidx.glance.text.TextStyle import de.timklge.karooheadwind.TemperatureUnit -import de.timklge.karooheadwind.WeatherInterpretation +import de.timklge.karooheadwind.weatherprovider.WeatherInterpretation import io.hammerhead.karooext.KarooSystemService import kotlin.math.absoluteValue import kotlin.math.roundToInt diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/RelativeHumidityDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/RelativeHumidityDataType.kt index 60a8772..a665c67 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/RelativeHumidityDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/RelativeHumidityDataType.kt @@ -1,10 +1,11 @@ package de.timklge.karooheadwind.datatypes import android.content.Context -import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse +import de.timklge.karooheadwind.weatherprovider.WeatherData +import io.hammerhead.karooext.KarooSystemService -class RelativeHumidityDataType(context: Context) : BaseDataType(context, "relativeHumidity"){ - override fun getValue(data: OpenMeteoCurrentWeatherResponse): Double { - return data.current.relativeHumidity.toDouble() +class RelativeHumidityDataType(karooSystemService: KarooSystemService, context: Context) : BaseDataType(karooSystemService, context, "relativeHumidity"){ + override fun getValue(data: WeatherData): Double? { + return data.relativeHumidity } } \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/SealevelPressureDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/SealevelPressureDataType.kt index 23d44f2..26d2ef9 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/SealevelPressureDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/SealevelPressureDataType.kt @@ -1,10 +1,11 @@ package de.timklge.karooheadwind.datatypes import android.content.Context -import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse +import de.timklge.karooheadwind.weatherprovider.WeatherData +import io.hammerhead.karooext.KarooSystemService -class SealevelPressureDataType(context: Context) : BaseDataType(context, "sealevelPressure"){ - override fun getValue(data: OpenMeteoCurrentWeatherResponse): Double { - return data.current.sealevelPressure ?: 0.0 +class SealevelPressureDataType(karooSystemService: KarooSystemService, context: Context) : BaseDataType(karooSystemService, context, "sealevelPressure"){ + override fun getValue(data: WeatherData): Double? { + return data.sealevelPressure } } \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/SurfacePressureDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/SurfacePressureDataType.kt index f70a18a..936f150 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/SurfacePressureDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/SurfacePressureDataType.kt @@ -1,10 +1,11 @@ package de.timklge.karooheadwind.datatypes import android.content.Context -import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse +import de.timklge.karooheadwind.weatherprovider.WeatherData +import io.hammerhead.karooext.KarooSystemService -class SurfacePressureDataType(context: Context) : BaseDataType(context, "surfacePressure"){ - override fun getValue(data: OpenMeteoCurrentWeatherResponse): Double { - return data.current.surfacePressure +class SurfacePressureDataType(karooSystemService: KarooSystemService, context: Context) : BaseDataType(karooSystemService, context, "surfacePressure"){ + override fun getValue(data: WeatherData): Double? { + return data.surfacePressure } } \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TailwindAndRideSpeedDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TailwindAndRideSpeedDataType.kt index c7cc655..2fff84b 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TailwindAndRideSpeedDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TailwindAndRideSpeedDataType.kt @@ -119,11 +119,11 @@ class TailwindAndRideSpeedDataType( val flow = if (config.preview) { previewFlow(karooSystem.streamUserProfile()) } else { - combine(karooSystem.getRelativeHeadingFlow(context), context.streamCurrentWeatherData(), context.streamSettings(karooSystem), karooSystem.streamUserProfile(), streamSpeedInMs()) { headingResponse, weatherData, settings, userProfile, rideSpeedInMs -> + combine(karooSystem.getRelativeHeadingFlow(context), context.streamCurrentWeatherData(karooSystem), context.streamSettings(karooSystem), karooSystem.streamUserProfile(), streamSpeedInMs()) { headingResponse, weatherData, settings, userProfile, rideSpeedInMs -> val isImperial = userProfile.preferredUnit.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL - val absoluteWindDirection = weatherData.firstOrNull()?.data?.current?.windDirection - val windSpeed = weatherData.firstOrNull()?.data?.current?.windSpeed - val gustSpeed = weatherData.firstOrNull()?.data?.current?.windGusts + val absoluteWindDirection = weatherData?.windDirection + val windSpeed = weatherData?.windSpeed + val gustSpeed = weatherData?.windGusts val rideSpeed = if (isImperial){ rideSpeedInMs * 2.23694 } else { @@ -141,7 +141,7 @@ class TailwindAndRideSpeedDataType( 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){ + if (value == null || streamData.absoluteWindDirection == null || streamData.windSpeed == null){ var headingResponse = streamData.headingResponse if (headingResponse is HeadingResponse.Value && (streamData.absoluteWindDirection == null || streamData.windSpeed == null)){ diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TailwindDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TailwindDataType.kt index 48c78f6..a84f81b 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TailwindDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TailwindDataType.kt @@ -4,7 +4,6 @@ import android.content.Context import android.graphics.BitmapFactory import android.util.Log import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.intl.Locale import androidx.compose.ui.unit.DpSize import androidx.core.content.ContextCompat import androidx.glance.appwidget.ExperimentalGlanceRemoteViewsApi @@ -34,7 +33,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow @@ -99,11 +97,11 @@ class TailwindDataType( val flow = if (config.preview) { previewFlow(karooSystem.streamUserProfile()) } else { - combine(karooSystem.getRelativeHeadingFlow(context), context.streamCurrentWeatherData(), context.streamSettings(karooSystem), karooSystem.streamUserProfile(), streamSpeedInMs()) { headingResponse, weatherData, settings, userProfile, rideSpeedInMs -> + combine(karooSystem.getRelativeHeadingFlow(context), context.streamCurrentWeatherData(karooSystem), context.streamSettings(karooSystem), karooSystem.streamUserProfile(), streamSpeedInMs()) { headingResponse, weatherData, settings, userProfile, rideSpeedInMs -> val isImperial = userProfile.preferredUnit.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL - val absoluteWindDirection = weatherData.firstOrNull()?.data?.current?.windDirection - val windSpeed = weatherData.firstOrNull()?.data?.current?.windSpeed - val gustSpeed = weatherData.firstOrNull()?.data?.current?.windGusts + val absoluteWindDirection = weatherData?.windDirection + val windSpeed = weatherData?.windSpeed + val gustSpeed = weatherData?.windGusts val rideSpeed = if (isImperial){ rideSpeedInMs * 2.23694 } else { diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TemperatureDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TemperatureDataType.kt index ea69ab9..b5f0124 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TemperatureDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TemperatureDataType.kt @@ -1,10 +1,11 @@ package de.timklge.karooheadwind.datatypes import android.content.Context -import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse +import de.timklge.karooheadwind.weatherprovider.WeatherData +import io.hammerhead.karooext.KarooSystemService -class TemperatureDataType(context: Context) : BaseDataType(context, "temperature"){ - override fun getValue(data: OpenMeteoCurrentWeatherResponse): Double { - return data.current.temperature +class TemperatureDataType(karooSystemService: KarooSystemService, context: Context) : BaseDataType(karooSystemService, context, "temperature"){ + override fun getValue(data: WeatherData): Double { + return data.temperature } } \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TemperatureForecastDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TemperatureForecastDataType.kt index bf85e7f..165dc25 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TemperatureForecastDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/TemperatureForecastDataType.kt @@ -21,7 +21,7 @@ import androidx.glance.text.Text import androidx.glance.text.TextAlign import androidx.glance.text.TextStyle import de.timklge.karooheadwind.TemperatureUnit -import de.timklge.karooheadwind.WeatherInterpretation +import de.timklge.karooheadwind.weatherprovider.WeatherInterpretation import io.hammerhead.karooext.KarooSystemService import kotlin.math.absoluteValue diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/UserWindSpeedDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/UserWindSpeedDataType.kt index 062cf7d..b44ef80 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/UserWindSpeedDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/UserWindSpeedDataType.kt @@ -3,7 +3,7 @@ package de.timklge.karooheadwind.datatypes import android.content.Context import de.timklge.karooheadwind.HeadingResponse import de.timklge.karooheadwind.HeadwindSettings -import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse +import de.timklge.karooheadwind.weatherprovider.WeatherData import de.timklge.karooheadwind.WindDirectionIndicatorTextSetting import de.timklge.karooheadwind.getRelativeHeadingFlow import de.timklge.karooheadwind.streamCurrentWeatherData @@ -28,18 +28,18 @@ class UserWindSpeedDataType( private val context: Context ) : DataTypeImpl("karoo-headwind", "userwindSpeed"){ - data class StreamData(val headingResponse: HeadingResponse, val weatherResponse: OpenMeteoCurrentWeatherResponse?, val settings: HeadwindSettings) + data class StreamData(val headingResponse: HeadingResponse, val weatherResponse: WeatherData?, val settings: HeadwindSettings) companion object { fun streamValues(context: Context, karooSystem: KarooSystemService): Flow = flow { karooSystem.getRelativeHeadingFlow(context) - .combine(context.streamCurrentWeatherData()) { value, data -> value to data } + .combine(context.streamCurrentWeatherData(karooSystem)) { value, data -> value to data } .combine(context.streamSettings(karooSystem)) { (value, data), settings -> - StreamData(value, data.firstOrNull()?.data, settings) + StreamData(value, data, settings) } .filter { it.weatherResponse != null } .collect { streamData -> - val windSpeed = streamData.weatherResponse?.current?.windSpeed ?: 0.0 + val windSpeed = streamData.weatherResponse?.windSpeed ?: 0.0 val windDirection = (streamData.headingResponse as? HeadingResponse.Value)?.diff ?: 0.0 if (streamData.settings.windDirectionIndicatorTextSetting == WindDirectionIndicatorTextSetting.HEADWIND_SPEED){ 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 ac7b5f8..acb7d40 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherDataType.kt @@ -16,10 +16,9 @@ import de.timklge.karooheadwind.HeadingResponse import de.timklge.karooheadwind.HeadwindSettings import de.timklge.karooheadwind.KarooHeadwindExtension import de.timklge.karooheadwind.MainActivity -import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse -import de.timklge.karooheadwind.OpenMeteoData import de.timklge.karooheadwind.TemperatureUnit -import de.timklge.karooheadwind.WeatherInterpretation +import de.timklge.karooheadwind.weatherprovider.WeatherData +import de.timklge.karooheadwind.weatherprovider.WeatherInterpretation import de.timklge.karooheadwind.getHeadingFlow import de.timklge.karooheadwind.streamCurrentWeatherData import de.timklge.karooheadwind.streamSettings @@ -61,12 +60,12 @@ class WeatherDataType( override fun startStream(emitter: Emitter) { val job = CoroutineScope(Dispatchers.IO).launch { - val currentWeatherData = applicationContext.streamCurrentWeatherData() + val currentWeatherData = applicationContext.streamCurrentWeatherData(karooSystem) currentWeatherData .collect { data -> - Log.d(KarooHeadwindExtension.TAG, "Wind code: ${data.firstOrNull()?.data?.current?.weatherCode}") - emitter.onNext(StreamState.Streaming(DataPoint(dataTypeId, mapOf(DataType.Field.SINGLE to (data.firstOrNull()?.data?.current?.weatherCode?.toDouble() ?: 0.0))))) + Log.d(KarooHeadwindExtension.TAG, "Wind code: ${data?.weatherCode}") + emitter.onNext(StreamState.Streaming(DataPoint(dataTypeId, mapOf(DataType.Field.SINGLE to (data?.weatherCode?.toDouble() ?: 0.0))))) } } emitter.setCancellable { @@ -74,19 +73,15 @@ class WeatherDataType( } } - data class StreamData(val data: OpenMeteoCurrentWeatherResponse?, val settings: HeadwindSettings, + data class StreamData(val data: WeatherData?, val settings: HeadwindSettings, val profile: UserProfile? = null, val headingResponse: HeadingResponse? = null) private fun previewFlow(): Flow = flow { while (true){ emit(StreamData( - OpenMeteoCurrentWeatherResponse( - OpenMeteoData(Instant.now().epochSecond, 0, 20.0, 50, 3.0, 0, 1013.25, 980.0, 15.0, 30.0, 30.0, WeatherInterpretation.getKnownWeatherCodes().random()), - 0.0, 0.0, "Europe/Berlin", 30.0, 0, - - null - ), HeadwindSettings() - )) + WeatherData(Instant.now().epochSecond, 0.0, + 20.0, 50.0, 3.0, 0.0, 1013.25, 980.0, 15.0, 30.0, 30.0, + WeatherInterpretation.getKnownWeatherCodes().random(), isForecast = false), HeadwindSettings())) delay(5_000) } @@ -107,8 +102,8 @@ class WeatherDataType( val dataFlow = if (config.preview){ previewFlow() } else { - combine(context.streamCurrentWeatherData(), context.streamSettings(karooSystem), karooSystem.streamUserProfile(), karooSystem.getHeadingFlow(context)) { data, settings, profile, heading -> - StreamData(data.firstOrNull()?.data, settings, profile, heading) + combine(context.streamCurrentWeatherData(karooSystem), context.streamSettings(karooSystem), karooSystem.streamUserProfile(), karooSystem.getHeadingFlow(context)) { data, settings, profile, heading -> + StreamData(data, settings, profile, heading) } } @@ -124,9 +119,9 @@ class WeatherDataType( return@collect } - val interpretation = WeatherInterpretation.fromWeatherCode(data.current.weatherCode) - val formattedTime = timeFormatter.format(Instant.ofEpochSecond(data.current.time)) - val formattedDate = getShortDateFormatter().format(Instant.ofEpochSecond(data.current.time)) + val interpretation = WeatherInterpretation.fromWeatherCode(data.weatherCode) + val formattedTime = timeFormatter.format(Instant.ofEpochSecond(data.time)) + val formattedDate = getShortDateFormatter().format(Instant.ofEpochSecond(data.time)) val result = glance.compose(context, DpSize.Unspecified) { var modifier = GlanceModifier.fillMaxSize() @@ -136,12 +131,12 @@ class WeatherDataType( Weather( baseBitmap, current = interpretation, - windBearing = data.current.windDirection.roundToInt(), - windSpeed = data.current.windSpeed.roundToInt(), - windGusts = data.current.windGusts.roundToInt(), - precipitation = data.current.precipitation, + windBearing = data.windDirection.roundToInt(), + windSpeed = data.windSpeed.roundToInt(), + windGusts = data.windGusts.roundToInt(), + precipitation = data.precipitation, precipitationProbability = null, - temperature = data.current.temperature.roundToInt(), + temperature = data.temperature.roundToInt(), temperatureUnit = if (userProfile?.preferredUnit?.temperature != UserProfile.PreferredUnit.UnitType.IMPERIAL) TemperatureUnit.CELSIUS else TemperatureUnit.FAHRENHEIT, timeLabel = formattedTime, rowAlignment = when (config.alignment){ 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 77dbd81..e7d32e3 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherForecastDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherForecastDataType.kt @@ -3,7 +3,7 @@ package de.timklge.karooheadwind.datatypes import android.graphics.Bitmap import androidx.compose.runtime.Composable import de.timklge.karooheadwind.TemperatureUnit -import de.timklge.karooheadwind.WeatherInterpretation +import de.timklge.karooheadwind.weatherprovider.WeatherInterpretation import io.hammerhead.karooext.KarooSystemService class WeatherForecastDataType(karooSystem: KarooSystemService) : ForecastDataType(karooSystem, "weatherForecast") { diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherView.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherView.kt index 1d9abdb..dbf0b73 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherView.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WeatherView.kt @@ -31,7 +31,7 @@ import androidx.glance.text.TextAlign import androidx.glance.text.TextStyle import de.timklge.karooheadwind.R import de.timklge.karooheadwind.TemperatureUnit -import de.timklge.karooheadwind.WeatherInterpretation +import de.timklge.karooheadwind.weatherprovider.WeatherInterpretation import java.time.ZoneId import java.time.format.DateTimeFormatter import java.util.Locale diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindDirectionDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindDirectionDataType.kt index 5b35103..980efa8 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindDirectionDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindDirectionDataType.kt @@ -17,7 +17,7 @@ import androidx.glance.text.FontFamily import androidx.glance.text.Text import androidx.glance.text.TextStyle import de.timklge.karooheadwind.KarooHeadwindExtension -import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse +import de.timklge.karooheadwind.weatherprovider.WeatherData import de.timklge.karooheadwind.streamDataFlow import io.hammerhead.karooext.KarooSystemService import io.hammerhead.karooext.internal.ViewEmitter @@ -35,7 +35,7 @@ import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.launch import kotlin.math.roundToInt -class WindDirectionDataType(val karooSystem: KarooSystemService, context: Context) : BaseDataType(context, "windDirection"){ +class WindDirectionDataType(val karooSystem: KarooSystemService, context: Context) : BaseDataType(karooSystem, context, "windDirection"){ @OptIn(ExperimentalGlanceRemoteViewsApi::class) private val glance = GlanceRemoteViews() @@ -48,8 +48,8 @@ class WindDirectionDataType(val karooSystem: KarooSystemService, context: Contex ) } - override fun getValue(data: OpenMeteoCurrentWeatherResponse): Double { - return data.current.windDirection + override fun getValue(data: WeatherData): Double { + return data.windDirection } private fun previewFlow(): Flow { diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindForecastDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindForecastDataType.kt index 55711ef..3507a29 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindForecastDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindForecastDataType.kt @@ -17,7 +17,6 @@ import androidx.glance.layout.ContentScale import androidx.glance.layout.Row import androidx.glance.layout.fillMaxHeight import androidx.glance.layout.fillMaxWidth -import androidx.glance.layout.height import androidx.glance.layout.padding import androidx.glance.layout.width import androidx.glance.text.FontFamily @@ -26,10 +25,9 @@ import androidx.glance.text.Text import androidx.glance.text.TextAlign import androidx.glance.text.TextStyle import de.timklge.karooheadwind.TemperatureUnit -import de.timklge.karooheadwind.WeatherInterpretation +import de.timklge.karooheadwind.weatherprovider.WeatherInterpretation import io.hammerhead.karooext.KarooSystemService import kotlin.math.absoluteValue -import kotlin.math.roundToInt @Composable fun WindForecast( diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindGustsDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindGustsDataType.kt index e6cce39..48e3043 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindGustsDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindGustsDataType.kt @@ -1,10 +1,11 @@ package de.timklge.karooheadwind.datatypes import android.content.Context -import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse +import de.timklge.karooheadwind.weatherprovider.WeatherData +import io.hammerhead.karooext.KarooSystemService -class WindGustsDataType(context: Context) : BaseDataType(context, "windGusts"){ - override fun getValue(data: OpenMeteoCurrentWeatherResponse): Double { - return data.current.windGusts +class WindGustsDataType(karooSystemService: KarooSystemService, context: Context) : BaseDataType(karooSystemService, context, "windGusts"){ + override fun getValue(data: WeatherData): Double { + return data.windGusts } } \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindSpeedDataType.kt b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindSpeedDataType.kt index 43f189f..bc2705a 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindSpeedDataType.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/datatypes/WindSpeedDataType.kt @@ -1,10 +1,11 @@ package de.timklge.karooheadwind.datatypes import android.content.Context -import de.timklge.karooheadwind.OpenMeteoCurrentWeatherResponse +import de.timklge.karooheadwind.weatherprovider.WeatherData +import io.hammerhead.karooext.KarooSystemService -class WindSpeedDataType(context: Context) : BaseDataType(context, "windSpeed"){ - override fun getValue(data: OpenMeteoCurrentWeatherResponse): Double { - return data.current.windSpeed +class WindSpeedDataType(karooSystemService: KarooSystemService, context: Context) : BaseDataType(karooSystemService, context, "windSpeed"){ + override fun getValue(data: WeatherData): Double { + return data.windSpeed } } \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/screens/WeatherScreen.kt b/app/src/main/kotlin/de/timklge/karooheadwind/screens/WeatherScreen.kt index 9b997a8..ec67a7c 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/screens/WeatherScreen.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/screens/WeatherScreen.kt @@ -1,7 +1,6 @@ package de.timklge.karooheadwind.screens import android.graphics.BitmapFactory -import android.util.Log import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer @@ -25,15 +24,15 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import de.timklge.karooheadwind.HeadwindStats -import de.timklge.karooheadwind.KarooHeadwindExtension import de.timklge.karooheadwind.R import de.timklge.karooheadwind.ServiceStatusSingleton import de.timklge.karooheadwind.TemperatureUnit -import de.timklge.karooheadwind.WeatherInterpretation +import de.timklge.karooheadwind.weatherprovider.WeatherInterpretation import de.timklge.karooheadwind.datatypes.ForecastDataType import de.timklge.karooheadwind.datatypes.WeatherDataType.Companion.timeFormatter import de.timklge.karooheadwind.datatypes.getShortDateFormatter import de.timklge.karooheadwind.getGpsCoordinateFlow +import de.timklge.karooheadwind.streamCurrentForecastWeatherData import de.timklge.karooheadwind.streamCurrentWeatherData import de.timklge.karooheadwind.streamStats import de.timklge.karooheadwind.streamUpcomingRoute @@ -50,12 +49,28 @@ import kotlin.math.roundToInt fun WeatherScreen(onFinish: () -> Unit) { var karooConnected by remember { mutableStateOf(null) } val ctx = LocalContext.current - val karooSystem = remember { KarooSystemService(ctx) } + val karooSystem = remember { KarooSystemService(ctx) } - val profile by karooSystem.streamUserProfile().collectAsStateWithLifecycle(null) - val stats by ctx.streamStats().collectAsStateWithLifecycle(HeadwindStats()) - val location by karooSystem.getGpsCoordinateFlow(ctx).collectAsStateWithLifecycle(null) - val weatherData by ctx.streamCurrentWeatherData().collectAsStateWithLifecycle(emptyList()) + val profileFlow = remember { karooSystem.streamUserProfile() } + val profile by profileFlow.collectAsStateWithLifecycle(null) + + val statsFlow = remember { ctx.streamStats() } + val stats by statsFlow.collectAsStateWithLifecycle(HeadwindStats()) + + val locationFlow = remember { karooSystem.getGpsCoordinateFlow(ctx) } + val location by locationFlow.collectAsStateWithLifecycle(null) + + val currentWeatherDataFlow = remember { ctx.streamCurrentWeatherData(karooSystem) } + val currentWeatherData by currentWeatherDataFlow.collectAsStateWithLifecycle(null) + + val forecastDataFlow = remember { ctx.streamCurrentForecastWeatherData() } + val forecastData by forecastDataFlow.collectAsStateWithLifecycle(null) + + val upcomingRouteFlow = remember { karooSystem.streamUpcomingRoute() } + val upcomingRoute by upcomingRouteFlow.collectAsStateWithLifecycle(null) + + val serviceStatusFlow = remember { ServiceStatusSingleton.getInstance().getServiceStatus() } + val serviceStatus by serviceStatusFlow.collectAsStateWithLifecycle(false) val baseBitmap = BitmapFactory.decodeResource( ctx.resources, @@ -85,21 +100,20 @@ fun WeatherScreen(onFinish: () -> Unit) { ) } - val currentWeatherData = weatherData.firstOrNull()?.data - val requestedWeatherPosition = weatherData.firstOrNull()?.requestedPosition + val requestedWeatherPosition = forecastData?.data?.firstOrNull()?.coords - val formattedTime = currentWeatherData?.let { timeFormatter.format(Instant.ofEpochSecond(currentWeatherData.current.time)) } - val formattedDate = currentWeatherData?.let { getShortDateFormatter().format(Instant.ofEpochSecond(currentWeatherData.current.time)) } + val formattedTime = currentWeatherData?.let { timeFormatter.format(Instant.ofEpochSecond(it.time)) } + val formattedDate = currentWeatherData?.let { getShortDateFormatter().format(Instant.ofEpochSecond(it.time)) } if (karooConnected == true && currentWeatherData != null) { WeatherWidget( baseBitmap = baseBitmap, - current = WeatherInterpretation.fromWeatherCode(currentWeatherData.current.weatherCode), - windBearing = currentWeatherData.current.windDirection.roundToInt(), - windSpeed = currentWeatherData.current.windSpeed.roundToInt(), - windGusts = currentWeatherData.current.windGusts.roundToInt(), - precipitation = currentWeatherData.current.precipitation, - temperature = currentWeatherData.current.temperature.toInt(), + current = WeatherInterpretation.fromWeatherCode(currentWeatherData?.weatherCode), + windBearing = currentWeatherData?.windDirection?.roundToInt() ?: 0, + windSpeed = currentWeatherData?.windSpeed?.roundToInt() ?: 0, + windGusts = currentWeatherData?.windGusts?.roundToInt() ?: 0, + precipitation = currentWeatherData?.precipitation ?: 0.0, + temperature = currentWeatherData?.temperature?.toInt() ?: 0, temperatureUnit = if(profile?.preferredUnit?.temperature == UserProfile.PreferredUnit.UnitType.METRIC) TemperatureUnit.CELSIUS else TemperatureUnit.FAHRENHEIT, timeLabel = formattedTime, dateLabel = formattedDate, @@ -113,8 +127,6 @@ fun WeatherScreen(onFinish: () -> Unit) { val lastPositionDistanceStr = lastPosition?.let { dist -> " (${dist.roundToInt()} km away)" } ?: "" - val serviceStatus by ServiceStatusSingleton.getInstance().getServiceStatus().collectAsStateWithLifecycle(false) - if (!serviceStatus){ Text( modifier = Modifier.padding(5.dp), @@ -178,21 +190,19 @@ fun WeatherScreen(onFinish: () -> Unit) { ) } - val upcomingRoute by karooSystem.streamUpcomingRoute().collectAsStateWithLifecycle(null) - for (index in 1..12){ - val positionIndex = if (weatherData.size == 1) 0 else index + val positionIndex = if (forecastData?.data?.size == 1) 0 else index - if (weatherData.getOrNull(positionIndex) == null) break - if (index >= (weatherData.getOrNull(positionIndex)?.data?.forecastData?.weatherCode?.size ?: 0)) { + if (forecastData?.data?.getOrNull(positionIndex) == null) break + if (index >= (forecastData?.data?.getOrNull(positionIndex)?.forecasts?.size ?: 0)) { break } - val data = weatherData.getOrNull(positionIndex)?.data - val distanceAlongRoute = weatherData.getOrNull(positionIndex)?.requestedPosition?.distanceAlongRoute - val position = weatherData.getOrNull(positionIndex)?.requestedPosition?.let { "${(it.distanceAlongRoute?.div(1000.0))?.toInt()} at ${it.lat}, ${it.lon}" } + val data = forecastData?.data?.getOrNull(positionIndex) + val distanceAlongRoute = forecastData?.data?.getOrNull(positionIndex)?.coords?.distanceAlongRoute + val position = forecastData?.data?.getOrNull(positionIndex)?.coords?.let { "${(it.distanceAlongRoute?.div(1000.0))?.toInt()} at ${it.lat}, ${it.lon}" } - Log.d(KarooHeadwindExtension.TAG, "Distance along route index ${positionIndex}: $position") + // Log.d(KarooHeadwindExtension.TAG, "Distance along route index ${positionIndex}: $position") if (index > 1) { Spacer( @@ -209,29 +219,30 @@ fun WeatherScreen(onFinish: () -> Unit) { distanceAlongRoute?.minus(currentDistanceAlongRoute) } - val interpretation = WeatherInterpretation.fromWeatherCode(data?.forecastData?.weatherCode?.get(index) ?: 0) - val unixTime = data?.forecastData?.time?.get(index) ?: 0 + val weatherData = data?.forecasts?.getOrNull(index) + val interpretation = WeatherInterpretation.fromWeatherCode(weatherData?.weatherCode ?: 0) + val unixTime = weatherData?.time ?: 0 val formattedForecastTime = ForecastDataType.timeFormatter.format(Instant.ofEpochSecond(unixTime)) val formattedForecastDate = getShortDateFormatter().format(Instant.ofEpochSecond(unixTime)) WeatherWidget( baseBitmap, current = interpretation, - windBearing = data?.forecastData?.windDirection?.get(index)?.roundToInt() ?: 0, - windSpeed = data?.forecastData?.windSpeed?.get(index)?.roundToInt() ?: 0, - windGusts = data?.forecastData?.windGusts?.get(index)?.roundToInt() ?: 0, - precipitation = data?.forecastData?.precipitation?.get(index) ?: 0.0, - temperature = data?.forecastData?.temperature?.get(index)?.roundToInt() ?: 0, + windBearing = weatherData?.windDirection?.roundToInt() ?: 0, + windSpeed = weatherData?.windSpeed?.roundToInt() ?: 0, + windGusts = weatherData?.windGusts?.roundToInt() ?: 0, + precipitation = weatherData?.precipitation ?: 0.0, + temperature = weatherData?.temperature?.toInt() ?: 0, temperatureUnit = if (profile?.preferredUnit?.temperature != UserProfile.PreferredUnit.UnitType.IMPERIAL) TemperatureUnit.CELSIUS else TemperatureUnit.FAHRENHEIT, timeLabel = formattedForecastTime, dateLabel = formattedForecastDate, distance = distanceFromCurrent, includeDistanceLabel = true, - precipitationProbability = data?.forecastData?.precipitationProbability?.get(index) ?: 0, + precipitationProbability = weatherData?.precipitationProbability?.toInt() ?: 0, isImperial = profile?.preferredUnit?.distance == UserProfile.PreferredUnit.UnitType.IMPERIAL ) } Spacer(modifier = Modifier.padding(30.dp)) } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/screens/WeatherWidget.kt b/app/src/main/kotlin/de/timklge/karooheadwind/screens/WeatherWidget.kt index ba3f5b6..7854b2b 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/screens/WeatherWidget.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/screens/WeatherWidget.kt @@ -25,7 +25,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import de.timklge.karooheadwind.R import de.timklge.karooheadwind.TemperatureUnit -import de.timklge.karooheadwind.WeatherInterpretation +import de.timklge.karooheadwind.weatherprovider.WeatherInterpretation import de.timklge.karooheadwind.datatypes.getArrowBitmapByBearing import de.timklge.karooheadwind.datatypes.getWeatherIcon import kotlin.math.absoluteValue @@ -65,7 +65,7 @@ fun WeatherWidget( ) } - if (distance != null) { + if (distance != null && distance > 200) { val distanceInUserUnit = (distance / (if(!isImperial) 1000.0 else 1609.34)).toInt() val label = "${distanceInUserUnit.absoluteValue}${if(!isImperial) "km" else "mi"}" val text = if (includeDistanceLabel){ @@ -154,7 +154,7 @@ fun WeatherWidget( modifier = Modifier.padding(top = 4.dp) ) { Image( - bitmap = getArrowBitmapByBearing(baseBitmap, windBearing).asImageBitmap(), + bitmap = getArrowBitmapByBearing(baseBitmap, windBearing + 180).asImageBitmap(), colorFilter = ColorFilter.tint(Color.Black), contentDescription = "Wind direction", modifier = Modifier.size(20.dp) diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/Utils.kt b/app/src/main/kotlin/de/timklge/karooheadwind/util/AngleDifference.kt similarity index 90% rename from app/src/main/kotlin/de/timklge/karooheadwind/Utils.kt rename to app/src/main/kotlin/de/timklge/karooheadwind/util/AngleDifference.kt index 512e795..ca7a7ed 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/Utils.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/util/AngleDifference.kt @@ -1,4 +1,4 @@ -package de.timklge.karooheadwind +package de.timklge.karooheadwind.util import kotlin.math.abs diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/util/Gzip.kt b/app/src/main/kotlin/de/timklge/karooheadwind/util/Gzip.kt new file mode 100644 index 0000000..21dc5d4 --- /dev/null +++ b/app/src/main/kotlin/de/timklge/karooheadwind/util/Gzip.kt @@ -0,0 +1,19 @@ +package de.timklge.karooheadwind.util + +import io.hammerhead.karooext.models.HttpResponseState +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.util.zip.GZIPInputStream + +suspend fun ungzip(response: HttpResponseState.Complete): String { + val inputStream = java.io.ByteArrayInputStream(response.body ?: ByteArray(0)) + val lowercaseHeaders = response.headers.map { (k: String, v: String) -> k.lowercase() to v.lowercase() }.toMap() + val isGzippedResponse = lowercaseHeaders["content-encoding"]?.contains("gzip") == true + + return if(isGzippedResponse){ + val gzipStream = withContext(Dispatchers.IO) { GZIPInputStream(inputStream) } + gzipStream.use { stream -> String(stream.readBytes()) } + } else { + inputStream.use { stream -> String(stream.readBytes()) } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/WeatherData.kt b/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/WeatherData.kt new file mode 100644 index 0000000..1886e91 --- /dev/null +++ b/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/WeatherData.kt @@ -0,0 +1,21 @@ +package de.timklge.karooheadwind.weatherprovider + +import kotlinx.serialization.Serializable + +@Serializable +data class WeatherData( + val time: Long, + val temperature: Double, + val relativeHumidity: Double? = null, + val precipitation: Double, + val precipitationProbability: Double? = null, + val cloudCover: Double? = null, + val sealevelPressure: Double? = null, + val surfacePressure: Double? = null, + val windSpeed: Double, + val windDirection: Double, + val windGusts: Double, + val weatherCode: Int, + val isForecast: Boolean +) + diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/WeatherDataForLocation.kt b/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/WeatherDataForLocation.kt new file mode 100644 index 0000000..d9f1254 --- /dev/null +++ b/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/WeatherDataForLocation.kt @@ -0,0 +1,13 @@ +package de.timklge.karooheadwind.weatherprovider + +import de.timklge.karooheadwind.datatypes.GpsCoordinates +import kotlinx.serialization.Serializable + +@Serializable +data class WeatherDataForLocation( + val current: WeatherData, + val coords: GpsCoordinates, + val timezone: String? = null, + val elevation: Double? = null, + val forecasts: List? = null, +) \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/WeatherDataResponse.kt b/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/WeatherDataResponse.kt new file mode 100644 index 0000000..36633a4 --- /dev/null +++ b/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/WeatherDataResponse.kt @@ -0,0 +1,11 @@ +package de.timklge.karooheadwind.weatherprovider + +import de.timklge.karooheadwind.WeatherDataProvider +import kotlinx.serialization.Serializable + +@Serializable +data class WeatherDataResponse( + val error: String? = null, + val provider: WeatherDataProvider, + val data: List, +) \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/WeatherInterpretation.kt b/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/WeatherInterpretation.kt new file mode 100644 index 0000000..7c5fd52 --- /dev/null +++ b/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/WeatherInterpretation.kt @@ -0,0 +1,22 @@ +package de.timklge.karooheadwind.weatherprovider + +enum class WeatherInterpretation { + CLEAR, CLOUDY, RAINY, SNOWY, DRIZZLE, THUNDERSTORM, UNKNOWN; + + companion object { + // WMO weather interpretation codes (WW) + fun fromWeatherCode(code: Int?): WeatherInterpretation { + return when(code){ + 0 -> CLEAR + 1, 2, 3 -> CLOUDY + 45, 48, 61, 63, 65, 66, 67, 80, 81, 82 -> RAINY + 71, 73, 75, 77, 85, 86 -> SNOWY + 51, 53, 55, 56, 57 -> DRIZZLE + 95, 96, 99 -> THUNDERSTORM + else -> UNKNOWN + } + } + + fun getKnownWeatherCodes(): Set = setOf(0, 1, 2, 3, 45, 48, 61, 63, 65, 66, 67, 80, 81, 82, 71, 73, 75, 77, 85, 86, 51, 53, 55, 56, 57, 95, 96, 99) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/WeatherProvider.kt b/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/WeatherProvider.kt similarity index 74% rename from app/src/main/kotlin/de/timklge/karooheadwind/WeatherProvider.kt rename to app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/WeatherProvider.kt index 47d9fb7..40c0365 100644 --- a/app/src/main/kotlin/de/timklge/karooheadwind/WeatherProvider.kt +++ b/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/WeatherProvider.kt @@ -1,8 +1,8 @@ -package de.timklge.karooheadwind +package de.timklge.karooheadwind.weatherprovider +import de.timklge.karooheadwind.HeadwindSettings import de.timklge.karooheadwind.datatypes.GpsCoordinates import io.hammerhead.karooext.KarooSystemService -import io.hammerhead.karooext.models.HttpResponseState import io.hammerhead.karooext.models.UserProfile interface WeatherProvider { @@ -11,5 +11,5 @@ interface WeatherProvider { coordinates: List, settings: HeadwindSettings, profile: UserProfile? - ): HttpResponseState.Complete + ): WeatherDataResponse } \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/WeatherProviderException.kt b/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/WeatherProviderException.kt new file mode 100644 index 0000000..ef24a9f --- /dev/null +++ b/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/WeatherProviderException.kt @@ -0,0 +1,3 @@ +package de.timklge.karooheadwind.weatherprovider + +class WeatherProviderException(val statusCode: Int, message: String) : Exception(message) \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/WeatherProviderFactory.kt b/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/WeatherProviderFactory.kt new file mode 100644 index 0000000..bc8c6cd --- /dev/null +++ b/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/WeatherProviderFactory.kt @@ -0,0 +1,82 @@ +package de.timklge.karooheadwind.weatherprovider + +import android.util.Log +import de.timklge.karooheadwind.HeadwindSettings +import de.timklge.karooheadwind.KarooHeadwindExtension +import de.timklge.karooheadwind.weatherprovider.openmeteo.OpenMeteoWeatherProvider +import de.timklge.karooheadwind.weatherprovider.openweathermap.OpenWeatherMapWeatherProvider +import de.timklge.karooheadwind.WeatherDataProvider +import de.timklge.karooheadwind.datatypes.GpsCoordinates +import io.hammerhead.karooext.KarooSystemService +import io.hammerhead.karooext.models.UserProfile +import java.time.LocalDate + +object WeatherProviderFactory { + private var openWeatherMapConsecutiveFailures = 0 + private var openWeatherMapTotalFailures = 0 + private var openMeteoSuccessfulAfterFailures = false + private var fallbackUntilDate: LocalDate? = null + + private const val MAX_FAILURES_BEFORE_TEMP_FALLBACK = 3 + private const val MAX_FAILURES_BEFORE_DAILY_FALLBACK = 20 + + suspend fun makeWeatherRequest( + karooSystemService: KarooSystemService, + gpsCoordinates: List, + settings: HeadwindSettings, + profile: UserProfile? + ): WeatherDataResponse { + val provider = getProvider(settings) + + try { + val response = provider.getWeatherData(karooSystemService, gpsCoordinates, settings, profile) + + return response + } catch(e: Throwable){ + Log.d(KarooHeadwindExtension.TAG, "Weather request failed: $e") + + if (provider is OpenWeatherMapWeatherProvider && (e is WeatherProviderException && (e.statusCode == 401 || e.statusCode == 403))) { + handleOpenWeatherMapFailure() + } + + throw e; + } + } + + private fun getProvider(settings: HeadwindSettings): WeatherProvider { + val currentDate = LocalDate.now() + + if (fallbackUntilDate != null && !currentDate.isAfter(fallbackUntilDate)) { + Log.d(KarooHeadwindExtension.TAG, "Using fallback OpenMeteo until $fallbackUntilDate") + return OpenMeteoWeatherProvider() + } + + + if (settings.weatherProvider == WeatherDataProvider.OPEN_WEATHER_MAP && + openWeatherMapConsecutiveFailures >= MAX_FAILURES_BEFORE_TEMP_FALLBACK + ) { + openWeatherMapConsecutiveFailures = 0 + Log.d(KarooHeadwindExtension.TAG, "Using temporary fallback OpenMeteo") + return OpenMeteoWeatherProvider() + } + + + return when (settings.weatherProvider) { + WeatherDataProvider.OPEN_METEO -> OpenMeteoWeatherProvider() + WeatherDataProvider.OPEN_WEATHER_MAP -> OpenWeatherMapWeatherProvider(settings.openWeatherMapApiKey) + } + } + + private fun handleOpenWeatherMapFailure() { + openWeatherMapConsecutiveFailures++ + openWeatherMapTotalFailures++ + + Log.d(KarooHeadwindExtension.TAG, "OpenWeatherMap failed $openWeatherMapConsecutiveFailures times consecutive, $openWeatherMapTotalFailures total times") + + if (openWeatherMapTotalFailures >= MAX_FAILURES_BEFORE_DAILY_FALLBACK && openMeteoSuccessfulAfterFailures) { + fallbackUntilDate = LocalDate.now() + Log.d(KarooHeadwindExtension.TAG, "Activated daily fallback OpenMeteo until $fallbackUntilDate") + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openmeteo/OpenMeteoWeatherData.kt b/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openmeteo/OpenMeteoWeatherData.kt new file mode 100644 index 0000000..1c66122 --- /dev/null +++ b/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openmeteo/OpenMeteoWeatherData.kt @@ -0,0 +1,36 @@ +package de.timklge.karooheadwind.weatherprovider.openmeteo + +import de.timklge.karooheadwind.weatherprovider.WeatherData +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class OpenMeteoWeatherData( + val time: Long, val interval: Int, + @SerialName("temperature_2m") val temperature: Double, + @SerialName("relative_humidity_2m") val relativeHumidity: Int, + @SerialName("precipitation") val precipitation: Double, + @SerialName("cloud_cover") val cloudCover: Int, + @SerialName("surface_pressure") val surfacePressure: Double, + @SerialName("pressure_msl") val sealevelPressure: Double? = null, + @SerialName("wind_speed_10m") val windSpeed: Double, + @SerialName("wind_direction_10m") val windDirection: Double, + @SerialName("wind_gusts_10m") val windGusts: Double, + @SerialName("weather_code") val weatherCode: Int, +) { + fun toWeatherData(): WeatherData = WeatherData( + temperature = temperature, + relativeHumidity = relativeHumidity.toDouble(), + precipitation = precipitation, + cloudCover = cloudCover.toDouble(), + surfacePressure = surfacePressure, + sealevelPressure = sealevelPressure, + windSpeed = windSpeed, + windDirection = windDirection, + windGusts = windGusts, + weatherCode = weatherCode, + time = time, + isForecast = false, + ) +} + diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openmeteo/OpenMeteoWeatherDataForLocation.kt b/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openmeteo/OpenMeteoWeatherDataForLocation.kt new file mode 100644 index 0000000..9467cab --- /dev/null +++ b/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openmeteo/OpenMeteoWeatherDataForLocation.kt @@ -0,0 +1,28 @@ +package de.timklge.karooheadwind.weatherprovider.openmeteo + +import de.timklge.karooheadwind.datatypes.GpsCoordinates +import de.timklge.karooheadwind.weatherprovider.WeatherDataForLocation +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class OpenMeteoWeatherDataForLocation( + val current: OpenMeteoWeatherData, + val latitude: Double, + val longitude: Double, + val timezone: String, + val elevation: Double, + @SerialName("utc_offset_seconds") val utfOffsetSeconds: Int, + @SerialName("hourly") val forecastData: OpenMeteoWeatherForecastData?, +) { + fun toWeatherDataForLocation(distanceAlongRoute: Double?): WeatherDataForLocation { + val forecasts = forecastData?.toWeatherData() + return WeatherDataForLocation( + current = current.toWeatherData(), + coords = GpsCoordinates(latitude, longitude, bearing = null, distanceAlongRoute = distanceAlongRoute), + timezone = timezone, + elevation = elevation, + forecasts = forecasts + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openmeteo/OpenMeteoWeatherForecastData.kt b/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openmeteo/OpenMeteoWeatherForecastData.kt new file mode 100644 index 0000000..c9c6035 --- /dev/null +++ b/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openmeteo/OpenMeteoWeatherForecastData.kt @@ -0,0 +1,32 @@ +package de.timklge.karooheadwind.weatherprovider.openmeteo + +import de.timklge.karooheadwind.weatherprovider.WeatherData +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class OpenMeteoWeatherForecastData( + @SerialName("time") val time: List, + @SerialName("temperature_2m") val temperature: List, + @SerialName("precipitation_probability") val precipitationProbability: List, + @SerialName("precipitation") val precipitation: List, + @SerialName("weather_code") val weatherCode: List, + @SerialName("wind_speed_10m") val windSpeed: List, + @SerialName("wind_direction_10m") val windDirection: List, + @SerialName("wind_gusts_10m") val windGusts: List, +) { + fun toWeatherData(): List { + return time.mapIndexed { index, t -> + WeatherData( + temperature = temperature[index], + precipitation = precipitation[index], + windSpeed = windSpeed[index], + windDirection = windDirection[index], + windGusts = windGusts[index], + weatherCode = weatherCode[index], + time = t, + isForecast = true, + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openmeteo/OpenMeteoWeatherProvider.kt b/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openmeteo/OpenMeteoWeatherProvider.kt new file mode 100644 index 0000000..8427b9c --- /dev/null +++ b/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openmeteo/OpenMeteoWeatherProvider.kt @@ -0,0 +1,103 @@ +package de.timklge.karooheadwind.weatherprovider.openmeteo + +import android.util.Log +import de.timklge.karooheadwind.HeadwindSettings +import de.timklge.karooheadwind.KarooHeadwindExtension +import de.timklge.karooheadwind.PrecipitationUnit +import de.timklge.karooheadwind.TemperatureUnit +import de.timklge.karooheadwind.WeatherDataProvider +import de.timklge.karooheadwind.datatypes.GpsCoordinates +import de.timklge.karooheadwind.jsonWithUnknownKeys +import de.timklge.karooheadwind.util.ungzip +import de.timklge.karooheadwind.weatherprovider.WeatherDataResponse +import de.timklge.karooheadwind.weatherprovider.WeatherProvider +import de.timklge.karooheadwind.weatherprovider.WeatherProviderException +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 java.util.Locale +import kotlin.time.Duration.Companion.seconds + +class OpenMeteoWeatherProvider : WeatherProvider { + @OptIn(FlowPreview::class) + private suspend fun makeOpenMeteoWeatherRequest(karooSystemService: KarooSystemService, gpsCoordinates: List, settings: HeadwindSettings, profile: UserProfile?): String { + 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 + + val response = callbackFlow { + // https://api.open-meteo.com/v1/forecast?latitude=52.52&longitude=13.41¤t=surface_pressure,pressure_msl,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 lats = gpsCoordinates.joinToString(",") { String.format(Locale.US, "%.6f", it.lat) } + val lons = gpsCoordinates.joinToString(",") { String.format(Locale.US, "%.6f", it.lon) } + val url = "https://api.open-meteo.com/v1/forecast?latitude=${lats}&longitude=${lons}¤t=surface_pressure,pressure_msl,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 = karooSystemService.addConsumer( + OnHttpResponse.MakeHttpRequest( + "GET", + url, + waitForConnection = false, + headers = mapOf("User-Agent" to KarooHeadwindExtension.TAG, "Accept-Encoding" to "gzip"), + ), + onEvent = { event: OnHttpResponse -> + if (event.state is HttpResponseState.Complete){ + Log.d(KarooHeadwindExtension.TAG, "Http response received") + trySend(event.state as HttpResponseState.Complete) + close() + } + }, + onError = { err -> + Log.d(KarooHeadwindExtension.TAG, "Http error: $err") + close(WeatherProviderException(0, "Http error: $err")) + }) + awaitClose { + karooSystemService.removeConsumer(listenerId) + } + }.timeout(30.seconds).catch { e: Throwable -> + if (e is TimeoutCancellationException){ + emit(HttpResponseState.Complete(500, mapOf(), null, "Timeout")) + } else { + throw e + } + }.single() + + if (response.statusCode !in 200..299) { + Log.e(KarooHeadwindExtension.TAG, "OpenMeteo API request failed with status code ${response.statusCode}") + throw WeatherProviderException(response.statusCode, "OpenMeteo API request failed with status code ${response.statusCode}") + } + + return ungzip(response) + } + + override suspend fun getWeatherData( + karooSystem: KarooSystemService, + coordinates: List, + settings: HeadwindSettings, + profile: UserProfile? + ): WeatherDataResponse { + val openMeteoResponse = makeOpenMeteoWeatherRequest(karooSystem, coordinates, settings, profile) + + val weatherData = if (coordinates.size == 1) { + listOf(jsonWithUnknownKeys.decodeFromString(openMeteoResponse)) + } else { + jsonWithUnknownKeys.decodeFromString>(openMeteoResponse) + } + + val response = WeatherDataResponse( + provider = WeatherDataProvider.OPEN_METEO, + data = weatherData.zip(coordinates) { openMeteoWeatherDataForLocation, location -> + openMeteoWeatherDataForLocation.toWeatherDataForLocation(location.distanceAlongRoute) + } + ) + + return response + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openweathermap/OpenWeatherMapForecastData.kt b/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openweathermap/OpenWeatherMapForecastData.kt new file mode 100644 index 0000000..1591b84 --- /dev/null +++ b/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openweathermap/OpenWeatherMapForecastData.kt @@ -0,0 +1,39 @@ +package de.timklge.karooheadwind.weatherprovider.openweathermap + +import de.timklge.karooheadwind.weatherprovider.WeatherData +import kotlinx.serialization.Serializable + +@Serializable +data class OpenWeatherMapForecastData( + val dt: Long, + val temp: Double, + val feels_like: Double, + val pressure: Int, + val humidity: Int, + val clouds: Int, + val visibility: Int, + val wind_speed: Double, + val wind_deg: Int, + val wind_gust: Double? = null, + val pop: Double, + val rain: Rain? = null, + val snow: Snow? = null, + val weather: List +) { + fun toWeatherData(): WeatherData = WeatherData( + temperature = temp, + relativeHumidity = humidity.toDouble(), + precipitation = rain?.h1 ?: 0.0, + cloudCover = clouds.toDouble(), + surfacePressure = pressure.toDouble(), + sealevelPressure = pressure.toDouble(), // FIXME + windSpeed = wind_speed, + windDirection = wind_deg.toDouble(), + windGusts = wind_gust ?: wind_speed, + weatherCode = OpenWeatherMapWeatherProvider.convertWeatherCodeToOpenMeteo( + weather.firstOrNull()?.id ?: 800 + ), + time = dt, + isForecast = true + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openweathermap/OpenWeatherMapWeatherData.kt b/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openweathermap/OpenWeatherMapWeatherData.kt new file mode 100644 index 0000000..fd5828b --- /dev/null +++ b/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openweathermap/OpenWeatherMapWeatherData.kt @@ -0,0 +1,40 @@ +package de.timklge.karooheadwind.weatherprovider.openweathermap + +import de.timklge.karooheadwind.weatherprovider.WeatherData +import kotlinx.serialization.Serializable + +@Serializable +data class OpenWeatherMapWeatherData( + val dt: Long, + val sunrise: Long, + val sunset: Long, + val temp: Double, + val feels_like: Double, + val pressure: Int, + val humidity: Int, + val clouds: Int, + val visibility: Int, + val wind_speed: Double, + val wind_deg: Int, + val wind_gust: Double? = null, + val rain: Rain? = null, + val snow: Snow? = null, + val weather: List){ + + fun toWeatherData(): WeatherData = WeatherData( + temperature = temp, + relativeHumidity = humidity.toDouble(), + precipitation = rain?.h1 ?: 0.0, + cloudCover = clouds.toDouble(), + surfacePressure = pressure.toDouble(), + sealevelPressure = pressure.toDouble(), // FIXME + windSpeed = wind_speed, + windDirection = wind_deg.toDouble(), + windGusts = wind_gust ?: wind_speed, + weatherCode = OpenWeatherMapWeatherProvider.convertWeatherCodeToOpenMeteo( + weather.firstOrNull()?.id ?: 800 + ), + time = dt, + isForecast = false + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openweathermap/OpenWeatherMapWeatherDataForLocation.kt b/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openweathermap/OpenWeatherMapWeatherDataForLocation.kt new file mode 100644 index 0000000..3a78c56 --- /dev/null +++ b/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openweathermap/OpenWeatherMapWeatherDataForLocation.kt @@ -0,0 +1,23 @@ +package de.timklge.karooheadwind.weatherprovider.openweathermap + +import de.timklge.karooheadwind.datatypes.GpsCoordinates +import de.timklge.karooheadwind.weatherprovider.WeatherDataForLocation +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class OpenWeatherMapWeatherDataForLocation( + val lat: Double, + val lon: Double, + val timezone: String, + @SerialName("timezone_offset") val timezoneOffset: Int, + val current: OpenWeatherMapWeatherData, + val hourly: List +){ + fun toWeatherDataForLocation(distanceAlongRoute: Double?): WeatherDataForLocation = WeatherDataForLocation( + current = current.toWeatherData(), + coords = GpsCoordinates(lat, lon, bearing = null, distanceAlongRoute = distanceAlongRoute), + timezone = timezone, + forecasts = hourly.map { it.toWeatherData() } + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openweathermap/OpenWeatherMapWeatherProvider.kt b/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openweathermap/OpenWeatherMapWeatherProvider.kt new file mode 100644 index 0000000..0f60f77 --- /dev/null +++ b/app/src/main/kotlin/de/timklge/karooheadwind/weatherprovider/openweathermap/OpenWeatherMapWeatherProvider.kt @@ -0,0 +1,143 @@ +package de.timklge.karooheadwind.weatherprovider.openweathermap + +import android.util.Log +import de.timklge.karooheadwind.HeadwindSettings +import de.timklge.karooheadwind.KarooHeadwindExtension +import de.timklge.karooheadwind.WeatherDataProvider +import de.timklge.karooheadwind.datatypes.GpsCoordinates +import de.timklge.karooheadwind.jsonWithUnknownKeys +import de.timklge.karooheadwind.weatherprovider.WeatherDataForLocation +import de.timklge.karooheadwind.weatherprovider.WeatherDataResponse +import de.timklge.karooheadwind.weatherprovider.WeatherProvider +import de.timklge.karooheadwind.weatherprovider.WeatherProviderException +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 kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlin.time.Duration.Companion.seconds + + +@Serializable +data class Weather( + val id: Int, + val main: String, + val description: String, + val icon: String +) + +@Serializable +data class Rain( + @SerialName("1h") val h1: Double = 0.0, + @SerialName("3h") val h3: Double = 0.0 +) + +@Serializable +data class Snow( + @SerialName("1h") val h1: Double = 0.0, + @SerialName("3h") val h3: Double = 0.0 +) + +class OpenWeatherMapWeatherProvider(private val apiKey: String) : WeatherProvider { + companion object { + fun convertWeatherCodeToOpenMeteo(owmCode: Int): Int { + // Mapping OpenWeatherMap to WMO OpenMeteo + return when (owmCode) { + in 200..299 -> 95 // Thunderstorm + in 300..399 -> 51 // Drizzle + in 500..599 -> 61 // Rain + in 600..699 -> 71 // Snow + 800 -> 0 // Clear + in 801..804 -> 1 // Cloudy + else -> 0 + } + } + } + + override suspend fun getWeatherData( + karooSystem: KarooSystemService, + coordinates: List, + settings: HeadwindSettings, + profile: UserProfile? + ): WeatherDataResponse { + + val response = makeOpenWeatherMapRequest(karooSystem, coordinates, apiKey) + val responseBody = response.body?.let { String(it) } ?: throw Exception("Null response from OpenWeatherMap") + + val responses = mutableListOf() + + val openWeatherMapWeatherDataForLocation = jsonWithUnknownKeys.decodeFromString(responseBody) + responses.add(openWeatherMapWeatherDataForLocation.toWeatherDataForLocation(null)) + + // FIXME Route forecast + + return WeatherDataResponse( + provider = WeatherDataProvider.OPEN_WEATHER_MAP, + data = responses + ) + } + + @OptIn(FlowPreview::class) + private suspend fun makeOpenWeatherMapRequest( + service: KarooSystemService, + coordinates: List, + apiKey: String + ): HttpResponseState.Complete { + val response = callbackFlow { + val coordinate = coordinates.first() + // URL API 3.0 with onecall endpoint + val url = "https://api.openweathermap.org/data/3.0/onecall?lat=${coordinate.lat}&lon=${coordinate.lon}" + + "&appid=$apiKey&units=metric&exclude=minutely,daily,alerts" + + Log.d(KarooHeadwindExtension.TAG, "Http request to OpenWeatherMap API 3.0: $url") + + val listenerId = service.addConsumer( + OnHttpResponse.MakeHttpRequest( + "GET", + url, + waitForConnection = false, + headers = mapOf("User-Agent" to KarooHeadwindExtension.TAG) + ), + onEvent = { event: OnHttpResponse -> + if (event.state is HttpResponseState.Complete) { + Log.d(KarooHeadwindExtension.TAG, "Http response received from OpenWeatherMap") + trySend(event.state as HttpResponseState.Complete) + close() + } + }, + onError = { err -> + Log.e(KarooHeadwindExtension.TAG, "Http error: $err") + close(WeatherProviderException(0, err)) + } + ) + + awaitClose { + service.removeConsumer(listenerId) + } + }.timeout(30.seconds).catch { e: Throwable -> + if (e is TimeoutCancellationException) { + emit(HttpResponseState.Complete(500, mapOf(), null, "Timeout")) + } else { + throw e + } + }.single() + + if (response.statusCode == 401 || response.statusCode == 403){ + Log.e(KarooHeadwindExtension.TAG, "OpenWeatherMap API key is invalid or expired") + throw WeatherProviderException(response.statusCode, "OpenWeatherMap API key is invalid or expired") + } else if (response.statusCode !in 200..299) { + Log.e(KarooHeadwindExtension.TAG, "OpenWeatherMap API request failed with status code ${response.statusCode}") + throw WeatherProviderException(response.statusCode, "OpenWeatherMap API request failed with status code ${response.statusCode}") + } + + return response + } +} diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index a5de843..73309bc 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -8,9 +8,9 @@ #00ff00 #ff9930 - #FF2424 + #FF5454 #008000 #BB4300 - #B30000 + #A30000 \ No newline at end of file